package wang.yaojia.webterm.session

import kotlinx.coroutines.delay
import wang.yaojia.webterm.wire.Tunables
import kotlin.time.Duration

/**
 * WS keep-alive pacer (A5, plan §5). The Android analogue of iOS `SessionCore.PingScheduler`.
 *
 * OkHttp can drive an automatic ping, but the miss POLICY must be deterministic in tests, so
 * [run] sends one explicit ping every [interval] (default [Tunables.PING_INTERVAL] = 25 s) using
 * a virtual-time [delay]. A half-dead connection otherwise "looks connected" forever.
 *
 * Miss policy ([Tunables.PONG_MISS_LIMIT] = 2): one missed pong is tolerated; the moment two
 * CONSECUTIVE pings go unanswered the connection is declared dead and [run] returns
 * [Outcome.CONNECTION_LOST] — the caller (SessionEngine, A14) then feeds [ReconnectMachine.Input.Disconnected]
 * into the reconnect machine. Any answered ping resets the consecutive-miss counter.
 *
 * Zero real timers: [run] suspends on [delay], so under `runTest` a test advances virtual time
 * and never waits wall-clock time.
 */
public class PingScheduler(
    /** Ping period. Frozen default: [Tunables.PING_INTERVAL] (25 s). */
    public val interval: Duration = Tunables.PING_INTERVAL,
) {
    /** How [run] RETURNS. Cancellation is NOT a return value — it propagates as an exception. */
    public enum class Outcome {
        /** [Tunables.PONG_MISS_LIMIT] consecutive pings went unanswered — treat as disconnected. */
        CONNECTION_LOST,
    }

    /**
     * Loops until the connection dies or the coroutine is cancelled: [delay] one [interval], then
     * await [sendPing].
     *
     * [sendPing] returns true iff the pong arrived — the transport adapter (A7) owns ping/pong
     * resolution; false = a miss. Cancellation of the enclosing coroutine surfaces as a
     * `CancellationException` from [delay] and is allowed to PROPAGATE (structured concurrency): it
     * is a teardown signal owned by the caller (launch{} + cancel), and swallowing it would run the
     * rest of the loop in an already-cancelled scope and defeat `withTimeout`. [run] therefore only
     * ever *returns* [Outcome.CONNECTION_LOST]; teardown exits via the thrown exception.
     */
    public suspend fun run(sendPing: suspend () -> Boolean): Outcome {
        var consecutiveMisses = 0
        while (true) {
            // Not wrapped in try/catch: a CancellationException from delay() must propagate, not be
            // absorbed into a return value (see the KDoc — structured concurrency).
            delay(interval)
            val isPongReceived = sendPing()
            consecutiveMisses = if (isPongReceived) 0 else consecutiveMisses + 1
            if (consecutiveMisses >= Tunables.PONG_MISS_LIMIT) {
                return Outcome.CONNECTION_LOST
            }
        }
    }
}
