package wang.yaojia.webterm.session

import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds

/**
 * Pure reconnect back-off reducer (A5, plan §5). The Android analogue of iOS
 * `SessionCore.ReconnectMachine`, mirroring the web client's behaviour in
 * `public/terminal-session.ts`:
 *  - the first retry delay is 1 s (`reconnectDelay = 1000`),
 *  - each disconnect schedules the CURRENT delay, then doubles-and-caps it at 30 s
 *    (`Math.min(delay * 2, 30_000)`),
 *  - 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 [Effect] the caller (SessionEngine, A14) must perform. There is
 * no clock and no timer here; timing lives with the caller, which is what makes this fully
 * testable with zero real waits.
 *
 * DIVERGENCE FROM iOS (deliberate, plan §5 A5 "carries sessionId"): the machine also carries
 * the [sessionId] of the session being (re)connected. iOS's `.connected` returned a brand-new
 * `.initial`, dropping any id; here `.connected` resets ONLY the delay ladder and PRESERVES the
 * [sessionId], so every reconnect re-attaches to the same server session and the ring buffer is
 * replayed (invariant #2). The id is set via [withSessionId] when the server confirms the attach.
 */
public data class ReconnectMachine(
    /** Delay the NEXT [Input.Disconnected] will schedule (the current ladder rung). */
    public val nextRetryDelay: Duration,
    /** The session being reconnected; `null` before the server has issued an id. */
    public val sessionId: String?,
) {
    /** What happened to the connection (fed by the caller). */
    public sealed interface Input {
        /** The transport came up and the attach handshake succeeded. */
        public data object Connected : Input

        /** The transport dropped (or a connect attempt failed). */
        public data object Disconnected : Input

        /** The scheduled back-off timer elapsed. */
        public data object RetryTimerFired : Input

        /** The app came to the foreground — connect NOW, skip the timer. */
        public data object Foregrounded : Input

        /** The user tapped "retry" — connect NOW, skip the timer. */
        public data object UserRetry : Input
    }

    /** The single side effect the caller must perform for an [Input]. */
    public sealed interface Effect {
        /** Establish (or re-establish) the connection immediately. */
        public data object ConnectNow : Effect

        /** Arm a timer for [after]; feed [Input.RetryTimerFired] when it elapses. */
        public data class ScheduleRetry(val after: Duration) : Effect

        /** Nothing to do. */
        public data object None : Effect
    }

    /**
     * Feeds one input and returns `(next snapshot, effect to perform)`.
     *
     * - [Input.Connected]: back-off resets to the initial 1 s rung; the [sessionId] is kept
     *   (a successful reconnect is still the same session). No effect.
     * - [Input.Disconnected]: schedule a retry after the current delay; the next snapshot
     *   carries the doubled-and-capped delay.
     * - [Input.RetryTimerFired] / [Input.Foregrounded] / [Input.UserRetry]: connect NOW.
     *   Foreground/user-initiated attempts deliberately do NOT reset the ladder — only a
     *   successful [Input.Connected] does, so mashing retry can never turn back-off into
     *   hammering (no-reset-on-foreground).
     */
    public fun reduce(input: Input): Pair<ReconnectMachine, Effect> =
        when (input) {
            Input.Connected ->
                copy(nextRetryDelay = INITIAL_RETRY_DELAY) to Effect.None

            Input.Disconnected -> {
                val doubled = nextRetryDelay * BACKOFF_MULTIPLIER
                val next = copy(nextRetryDelay = minOf(doubled, MAX_RETRY_DELAY))
                next to Effect.ScheduleRetry(after = nextRetryDelay)
            }

            Input.RetryTimerFired, Input.Foregrounded, Input.UserRetry ->
                this to Effect.ConnectNow
        }

    /** Returns a copy carrying [sessionId] (adopted when the server confirms the attach). */
    public fun withSessionId(sessionId: String?): ReconnectMachine = copy(sessionId = sessionId)

    public companion object {
        /** First retry delay after a disconnect (`public/terminal-session.ts` `reconnectDelay`). */
        public val INITIAL_RETRY_DELAY: Duration = 1.seconds

        /** Back-off ceiling (`Math.min(delay * 2, 30_000)`). */
        public val MAX_RETRY_DELAY: Duration = 30.seconds

        /** Doubling factor per failed attempt. */
        public const val BACKOFF_MULTIPLIER: Int = 2

        /**
         * Fresh machine with no session id yet. Seed a known id (reconnecting an existing session)
         * with `ReconnectMachine.initial.withSessionId(id)`.
         */
        public val initial: ReconnectMachine =
            ReconnectMachine(nextRetryDelay = INITIAL_RETRY_DELAY, sessionId = null)
    }
}
