///|
/// The result of attempting to start a cooldown window.
pub(all) enum CooldownDecision {
  Acquired
  Active(retry_after_ms~ : Int64)
  /// The store could not answer, for example because a shared store's server
  /// is unreachable. The App decides whether the command runs (see
  /// `AppBuilder(cooldown_outage~)`) and reports the outage.
  Unavailable(reason~ : String)
} derive(Debug, Eq)

///|
/// Storage for command cooldown windows. Implementations own their clock.
pub(open) trait CooldownStore {
  /// Atomically start a window for `key`, or report the time remaining in the
  /// existing window. An active window is not extended by another attempt.
  async fn try_acquire(Self, key : String, window_ms~ : Int64) -> CooldownDecision
}

///|
/// In-process fixed windows. Expired keys are swept each time the map has
/// doubled since the last sweep, so memory follows the live windows however
/// long or short they are, and at least once a minute, so a burst's expired
/// keys do not stay once traffic returns to a few keys.
pub struct InMemoryCooldownStore {
  priv now_ : () -> Int64
  priv cooldowns_ : Map[String, Int64]
  priv mut sweep_at_ : Int32
  priv mut swept_at_ms_ : Int64
}

///|
/// The fewest keys at which a sweep runs.
let cooldown_sweep_floor : Int32 = 64

///|
/// The longest an expired key stays when the map stops growing.
let cooldown_sweep_interval_ms : Int64 = 60_000L

///|
/// Create an empty store, optionally supplying a clock for deterministic tests.
///
/// ```mbt check
/// async test {
///   let store = @cooldown.InMemoryCooldownStore(now=() => 1_000L)
///   assert_eq(store.try_acquire("1:ping|global", window_ms=10_000L), Acquired)
///   assert_eq(
///     store.try_acquire("1:ping|global", window_ms=10_000L),
///     Active(retry_after_ms=10_000L),
///   )
/// }
/// ```
pub fn InMemoryCooldownStore::InMemoryCooldownStore(
  now~ : () -> Int64 = @clock.now_ms,
) -> InMemoryCooldownStore {
  {
    now_: now,
    cooldowns_: Map([]),
    sweep_at_: cooldown_sweep_floor,
    swept_at_ms_: now(),
  }
}

///|
pub extend InMemoryCooldownStore with CooldownStore::{try_acquire}

///|
pub impl CooldownStore for InMemoryCooldownStore with fn try_acquire(
  self,
  key,
  window_ms~,
) {
  let now_ms = (self.now_)()
  if self.cooldowns_.length() >= self.sweep_at_ ||
    now_ms - self.swept_at_ms_ >= cooldown_sweep_interval_ms {
    let expired : Array[String] = []
    for pending_key, expires_at in self.cooldowns_ {
      if expires_at <= now_ms {
        expired.push(pending_key)
      }
    }
    for pending_key in expired {
      self.cooldowns_.remove(pending_key) |> ignore
    }
    self.sweep_at_ = (self.cooldowns_.length() * 2).max(cooldown_sweep_floor)
    self.swept_at_ms_ = now_ms
  }
  match self.cooldowns_.get(key) {
    Some(expires_at) if expires_at > now_ms =>
      Active(retry_after_ms=expires_at - now_ms)
    _ => {
      self.cooldowns_[key] = now_ms + window_ms
      Acquired
    }
  }
}