Private/Subscriptions/Invoke-ElmSubscriptions.ps1

function Invoke-ElmSubscriptions {
    <#
    .SYNOPSIS
        Consumes the input queue and fires matching subscriptions, returning messages.

    .DESCRIPTION
        Sole consumer of the InputQueue in subscription-mode event loops.

        On each call:
          1. Timer subscriptions are checked against elapsed time. When an interval
             has elapsed, the handler is invoked and its return value is added to the
             output message list. Timer state (last-fired ms) is maintained in the
             caller-supplied TimerState hashtable across calls.

          2. All pending items are drained from the InputQueue. For each KeyDown event,
             matching Key subscriptions are found and their handlers invoked. Letter
             keys (A-Z) are matched case-insensitively: a 'Q' subscription fires for
             both lowercase 'q' (Modifiers=None) and uppercase 'Q' (Modifiers=Shift).

          3. Pass-through mode: when no Key subscriptions are defined, all KeyDown
             events are forwarded as raw messages so that UpdateFn-based key handling
             (used by existing demos) continues to work without modification.

          4. Legacy Tick messages (from -TickMs timer runspace) are always forwarded
             as-is for backward compatibility.

    .PARAMETER Subscriptions
        Array of subscription objects from New-ElmKeySub / New-ElmTimerSub.
        May be $null or empty, in which case pass-through mode is active.

    .PARAMETER InputQueue
        The ConcurrentQueue[PSCustomObject] from New-ElmTerminalDriver.

    .PARAMETER TimerState
        A hashtable maintained by the caller across loop iterations.
        Stores last-fired timestamps keyed by "Timer:<IntervalMs>".
        Pass the same instance on every call; Invoke-ElmSubscriptions mutates it.

    .OUTPUTS
        Object[] - zero or more message objects to dispatch to UpdateFn.
        Always returns an array (never $null).

    .EXAMPLE
        $timerState = @{}
        $subs = @(New-ElmKeySub -Key 'Q' -Handler { 'Quit' })
        $msgs = Invoke-ElmSubscriptions -Subscriptions $subs -InputQueue $queue -TimerState $timerState

    .NOTES
        Called by Invoke-ElmEventLoop on every iteration when a SubscriptionFn is set.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [AllowNull()]
        [object[]]$Subscriptions,

        [Parameter(Mandatory)]
        [object]$InputQueue,

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

    $msgs      = [System.Collections.Generic.List[object]]::new()
    $keySubs   = [System.Collections.Generic.List[object]]::new()
    $timerSubs = [System.Collections.Generic.List[object]]::new()
    $charSubs  = [System.Collections.Generic.List[object]]::new()

    if ($null -ne $Subscriptions) {
        foreach ($sub in $Subscriptions) {
            if ($null -eq $sub) { continue }
            if ($sub.Type -eq 'Key')   { $keySubs.Add($sub) }
            if ($sub.Type -eq 'Timer') { $timerSubs.Add($sub) }
            if ($sub.Type -eq 'Char')  { $charSubs.Add($sub) }
        }
    }

    # --- Timer subscriptions ---
    # Use Environment.TickCount for ms-precision elapsed time.
    # Stored as [int] in TimerState; difference arithmetic is safe for intervals
    # well under the ~24-day rollover period.
    $nowMs = [System.Environment]::TickCount

    foreach ($timerSub in $timerSubs) {
        $stateKey = "Timer:$($timerSub.IntervalMs)"
        if (-not $TimerState.ContainsKey($stateKey)) {
            $TimerState[$stateKey] = $nowMs
        }
        $elapsed = $nowMs - $TimerState[$stateKey]
        if ($elapsed -ge $timerSub.IntervalMs) {
            $TimerState[$stateKey] = $nowMs
            $msg = & $timerSub.Handler
            if ($null -ne $msg) {
                $msgs.Add($msg)
            }
        }
    }

    # --- Key + Char subscriptions ---
    $hasKeySubs  = $keySubs.Count -gt 0
    $hasCharSubs = $charSubs.Count -gt 0
    $shiftBit    = [int][System.ConsoleModifiers]::Shift

    $item = $null
    while ($InputQueue.TryDequeue([ref]$item)) {
        if ($item.Type -eq 'KeyDown') {
            if ($hasKeySubs -or $hasCharSubs) {
                $matched         = $false
                $itemKeyInt      = [int]$item.Key
                $itemModsInt     = [int]$item.Modifiers
                $isLetterKey     = ($itemKeyInt -ge 65 -and $itemKeyInt -le 90)

                if ($hasKeySubs) {
                    foreach ($keySub in $keySubs) {
                        $subModsInt = [int]$keySub.Modifiers
                        $compareModsInt = $itemModsInt

                        # Case-insensitive letter matching: strip Shift from the item's
                        # modifiers when the sub requests no modifiers and the key is A-Z.
                        if ($isLetterKey -and $subModsInt -eq 0) {
                            $compareModsInt = $itemModsInt -band (-bnot $shiftBit)
                        }

                        if ($item.Key -eq $keySub.ConsoleKey -and $compareModsInt -eq $subModsInt) {
                            $msg = & $keySub.Handler $item
                            if ($null -ne $msg) {
                                $msgs.Add($msg)
                            }
                            $matched = $true
                        }
                    }
                }

                # If no key sub matched, try char subs for printable characters.
                # Char subs fire only for Unicode 0x0020-0x007E (printable ASCII).
                if (-not $matched -and $hasCharSubs) {
                    $charCode = [int]$item.Char
                    if ($charCode -ge 0x20 -and $charCode -le 0x7E) {
                        foreach ($charSub in $charSubs) {
                            $msg = & $charSub.Handler $item
                            if ($null -ne $msg) {
                                $msgs.Add($msg)
                            }
                        }
                    }
                }
            } else {
                # Pass-through: no key subs or char subs - forward raw event to UpdateFn
                $msgs.Add($item)
            }
        } elseif ($item.Type -eq 'Tick') {
            # Legacy -TickMs tick: forward as-is for backward compatibility
            $msgs.Add($item)
        }
    }

    # Wrap in outer array to prevent PS pipeline from unwrapping a single element
    return $msgs.ToArray()
}