import WireProtocol

/// WS keep-alive pacer (T-iOS-5, plan §3.2 / §3.2.1).
///
/// `URLSessionWebSocketTask` has NO automatic ping (plan §1), so without this
/// a half-dead connection "looks connected" forever. `run` sends an explicit
/// ping every `interval` (default `Tunables.pingInterval` = 25 s) on the
/// injected clock.
///
/// Miss policy (`Tunables.pongMissLimit` = 2): one missed pong is tolerated;
/// the moment `pongMissLimit` CONSECUTIVE pings go unanswered the connection
/// is declared dead and `run` returns `.connectionLost` — the caller
/// (SessionEngine, T-iOS-10) then feeds `.disconnected` into
/// `ReconnectMachine`. Any answered ping resets the consecutive-miss counter.
///
/// Zero real timers: the clock is injected (`any Clock<Duration>`), so tests
/// drive a `FakeClock` and never wait wall-clock time.
public struct PingScheduler: Sendable {
    /// How `run` ended.
    public enum Outcome: Sendable, Equatable {
        /// `Tunables.pongMissLimit` consecutive pings went unanswered — treat
        /// the transport as disconnected.
        case connectionLost
        /// The surrounding task was cancelled (normal teardown, e.g. explicit
        /// close or transport swap) — NOT a connectivity verdict.
        case cancelled
    }

    /// Ping period. Frozen default: `Tunables.pingInterval` (25 s).
    public let interval: Duration

    public init(interval: Duration = Tunables.pingInterval) {
        self.interval = interval
    }

    /// Loops until the connection dies or the task is cancelled: sleep one
    /// `interval` on `clock`, then await `sendPing`.
    ///
    /// `sendPing` returns `true` iff the pong arrived — the transport adapter
    /// owns ping/pong resolution (e.g. racing `URLSessionWebSocketTask`'s
    /// `pongReceiveHandler` against its own deadline, T-iOS-9); `false` = miss.
    public func run(
        clock: any Clock<Duration>,
        sendPing: @Sendable () async -> Bool
    ) async -> Outcome {
        var consecutiveMisses = 0
        while true {
            do {
                try await clock.sleep(for: interval, tolerance: nil)
            } catch {
                // Clock.sleep only throws CancellationError (FakeClock mirrors
                // ContinuousClock semantics) — normal teardown, not a failure.
                return .cancelled
            }
            let isPongReceived = await sendPing()
            consecutiveMisses = isPongReceived ? 0 : consecutiveMisses + 1
            if consecutiveMisses >= Tunables.pongMissLimit {
                return .connectionLost
            }
        }
    }
}
