///|
/// How a connection ended. `InvalidSession` is opcode 9; everything else is a
/// close with its WebSocket code (`None` for an unclean end or a server
/// request to reconnect).
priv suberror ConnectionEnded {
  ConnectionEnded(code~ : Int32?, reason~ : String)
  InvalidSession(resumable~ : Bool)
} derive(Debug)

///|
/// The wait before the next connection attempt.
priv enum ReconnectDelay {
  /// Full-jitter exponential backoff under `cap_ms`, plus a 250 ms floor.
  Backoff(cap_ms~ : Int32)
  /// The 1-5 s Discord asks for before identifying after a non-resumable
  /// INVALID_SESSION.
  InvalidSession
}

///|
/// What the reconnect loop does after a connection ends.
priv enum AfterClose {
  /// The shard is closing.
  Stop(code~ : Int32?)
  /// Reconnecting cannot succeed.
  Fatal(code~ : Int32?)
  /// Connect again, resuming the session or identifying after clearing it.
  Reconnect(code~ : Int32?, resuming~ : Bool, clear_session~ : Bool)
}

///|
/// Reconnect bookkeeping across connections: consecutive attempts drive the
/// backoff, consecutive connector failures drive the stale-host fallback.
priv struct Reconnect {
  mut attempts : Int32
  mut connector_failures : Int32
  mut delay : ReconnectDelay?
}

///|
fn Reconnect::new() -> Reconnect {
  { attempts: 0, connector_failures: 0, delay: None, }
}

///|
/// The backoff ceiling: doubling from 1 s, capped at 30 s.
fn backoff_cap_ms(attempts : Int32) -> Int32 {
  let cap = 1000 * (1 << (if attempts > 5 { 5 } else { attempts }))
  if cap > 30000 {
    30000
  } else {
    cap
  }
}

///|
fn ReconnectDelay::sample_ms(
  self : ReconnectDelay,
  rand : @random.Rand,
) -> Int32 {
  match self {
    Backoff(cap_ms~) => rand.int(limit=cap_ms) + 250
    InvalidSession => 1000 + rand.int(limit=4000)
  }
}

///|
/// Record a failed connect. Returns `true` when the session's resume URL
/// should be dropped: a persisted one can point at a dead host, so three
/// consecutive failures fall back to the configured gateway URL.
fn Reconnect::on_connector_failure(
  self : Reconnect,
  has_session~ : Bool,
) -> Bool {
  self.attempts += 1
  self.connector_failures += 1
  self.delay = Some(Backoff(cap_ms=backoff_cap_ms(self.attempts)))
  self.connector_failures >= 3 && has_session
}

///|
fn Reconnect::on_connected(self : Reconnect) -> Unit {
  self.connector_failures = 0
}

///|
/// Classify a finished connection. `was_active` says it reached READY or
/// RESUMED, which starts the backoff over: only consecutive failures should
/// push the delay toward its cap.
fn Reconnect::after_close(
  self : Reconnect,
  ended : ConnectionEnded,
  closing~ : Bool,
  was_active~ : Bool,
  has_session~ : Bool,
) -> AfterClose {
  if was_active {
    self.attempts = 0
  }
  let (code, has_session, invalid_session) = match ended {
    ConnectionEnded(code~, ..) => (code, has_session, false)
    InvalidSession(resumable=true) => (None, has_session, false)
    InvalidSession(resumable=false) => (None, false, true)
  }
  if closing {
    return Stop(code~)
  }
  let policy = on_close(code, has_session)
  if policy is Fatal {
    return Fatal(code~)
  }
  self.attempts += 1
  self.delay = Some(
    if invalid_session {
      InvalidSession
    } else {
      Backoff(cap_ms=backoff_cap_ms(self.attempts))
    },
  )
  Reconnect(
    code~,
    resuming=policy is Resume,
    clear_session=policy is Reidentify,
  )
}