src/gquery.gleam

import gleam/option.{type Option}

/// Represents the full lifecycle of a remotely fetched value.
///
/// - `NotAsked`  — no fetch has been attempted yet.
/// - `Loading`   — a fetch is in progress. Carries optional stale data so the
///                 UI can display the previous value while revalidating.
/// - `Loaded`    — the last fetch succeeded. `at` is the Unix timestamp in
///                 milliseconds at which the data was recorded.
/// - `Failed`    — the last fetch failed with an error.
pub type Entry(data, err) {
  NotAsked
  Loading(stale: Option(data))
  Loaded(data: data, at: Int)
  Failed(err: err)
}

/// Pass to `stale_ms` to always consider an entry stale.
/// The fetch will fire on every call to `query`.
pub const always_stale: Int = 0

/// Pass to `stale_ms` to never consider a loaded entry stale.
/// The fetch fires only once; the data is kept until manually invalidated.
pub const never_stale: Int = 2_147_483_647

/// Returns `True` when the entry needs a fresh fetch.
///
/// - `NotAsked` and `Failed` are always considered stale.
/// - `Loading` is never stale (a fetch is already in flight).
/// - `Loaded` is stale when `now_ms - at > stale_ms`.
///
/// ```gleam
/// gleam_query.is_stale(entry, stale_ms: 30_000, now_ms: now())
/// ```
pub fn is_stale(entry: Entry(a, e), stale_ms: Int, now_ms: Int) -> Bool {
  case entry {
    NotAsked -> True
    Failed(_) -> True
    Loading(_) -> False
    Loaded(_, at) -> now_ms - at >= stale_ms
  }
}

/// Returns the most recent data held by the entry, if any.
///
/// Returns `Some(data)` for `Loaded` and `Loading(Some(data))` (stale-while-
/// revalidate). Returns `None` for `NotAsked`, `Failed`, and `Loading(None)`.
///
/// ```gleam
/// case gleam_query.get_data(entry) {
///   Some(contacts) -> render_list(contacts)
///   None -> render_empty()
/// }
/// ```
pub fn get_data(entry: Entry(a, e)) -> Option(a) {
  case entry {
    Loaded(data, _) -> option.Some(data)
    Loading(stale) -> stale
    NotAsked | Failed(_) -> option.None
  }
}

/// Marks a loaded entry as stale, transitioning it to `Loading` while
/// preserving the existing data for display during revalidation.
///
/// - `Loaded(data, _)` → `Loading(Some(data))`
/// - `Loading(_)`      → unchanged (fetch already in progress)
/// - `NotAsked`        → unchanged (nothing to preserve)
/// - `Failed(_)`       → `NotAsked` (retry from a clean state)
///
/// Call this after a successful mutation to trigger a background refetch
/// on the next access.
///
/// ```gleam
/// let contacts = dict.map_values(cache.contacts, fn(_, e) {
///   gleam_query.invalidate(e)
/// })
/// ```
pub fn invalidate(entry: Entry(a, e)) -> Entry(a, e) {
  case entry {
    Loaded(data, _) -> Loading(stale: option.Some(data))
    Loading(_) -> entry
    NotAsked -> entry
    Failed(_) -> NotAsked
  }
}

/// Creates an `Entry` from a fetch result and a current timestamp.
///
/// - `Ok(data)` → `Loaded(data, at: now_ms)`
/// - `Error(err)` → `Failed(err)`
///
/// Prefer `gleam_query/lustre.record` in Lustre apps — it captures the
/// current timestamp automatically.
///
/// ```gleam
/// let entry = gleam_query.record(result, now_ms: current_time_ms())
/// ```
pub fn record(result: Result(data, err), now_ms: Int) -> Entry(data, err) {
  case result {
    Ok(data) -> Loaded(data: data, at: now_ms)
    Error(err) -> Failed(err)
  }
}