/// Pure reconnect back-off reducer (T-iOS-5, plan §3.2).
///
/// Mirrors the web client's behaviour in `public/terminal-session.ts`:
/// - line 106: first retry delay is 1 s,
/// - lines 352-361 (`scheduleReconnect`): the CURRENT delay is scheduled,
///   then doubled and capped at 30 s (`Math.min(delay * 2, 30_000)`),
/// - line 248: a successful open resets the delay to 1 s.
/// Ladder: 1s → 2s → 4s → 8s → 16s → 30s → 30s …
///
/// PURE state machine: `reduce` never mutates the receiver — it returns a new
/// immutable snapshot plus the single side-effect the caller (SessionEngine,
/// T-iOS-10) must perform. No clock, no timers here; timing lives with the
/// caller, which is what makes this fully testable with zero real waits.
public struct ReconnectMachine: Sendable, Equatable {
    /// First retry delay after a disconnect
    /// (mirrors `public/terminal-session.ts:106` — `reconnectDelay = 1000`).
    static let initialRetryDelay: Duration = .seconds(1)

    /// Back-off ceiling
    /// (mirrors `public/terminal-session.ts:356` — `Math.min(delay * 2, 30_000)`).
    static let maxRetryDelay: Duration = .seconds(30)

    /// Doubling factor per failed attempt (same mirror line as the ceiling).
    static let backoffMultiplier = 2

    public enum Input: Sendable, Equatable {
        case connected
        case disconnected
        case retryTimerFired
        case foregrounded
        case userRetry
    }

    public enum Effect: Sendable, Equatable {
        case connectNow
        case scheduleRetry(after: Duration)
        case none
    }

    public static let initial = ReconnectMachine(nextRetryDelay: initialRetryDelay)

    /// Delay the NEXT `.disconnected` will schedule.
    private let nextRetryDelay: Duration

    private init(nextRetryDelay: Duration) {
        self.nextRetryDelay = nextRetryDelay
    }

    /// Feeds one input and returns `(next snapshot, effect to perform)`.
    ///
    /// - `.connected`: back-off resets to the initial 1 s ladder rung; no effect.
    /// - `.disconnected`: schedule a retry after the current delay; the next
    ///   snapshot carries the doubled (capped) delay.
    /// - `.retryTimerFired` / `.foregrounded` / `.userRetry`: connect NOW.
    ///   Foreground/user-initiated attempts deliberately do NOT reset the
    ///   ladder — only a successful `.connected` does, so mashing retry can
    ///   never turn back-off into hammering.
    public func reduce(_ input: Input) -> (ReconnectMachine, Effect) {
        switch input {
        case .connected:
            return (.initial, .none)
        case .disconnected:
            let doubled = nextRetryDelay * Self.backoffMultiplier
            let next = ReconnectMachine(nextRetryDelay: min(doubled, Self.maxRetryDelay))
            return (next, .scheduleRetry(after: nextRetryDelay))
        case .retryTimerFired, .foregrounded, .userRetry:
            return (self, .connectNow)
        }
    }
}
