src/lightspeed/async.gleam

//// LiveView-style async assign/task compatibility model.

import gleam/list
import gleam/option.{type Option, None, Some}

/// State of one async assignment.
pub type AsyncState(value) {
  Idle
  PendingDisconnected
  Loading(ref: Int)
  Succeeded(value: value)
  Failed(reason: String)
  Cancelled(reason: String)
}

/// Async runtime transition errors.
pub type AsyncError {
  UnknownTask(key: String)
  InvalidTransition(key: String, state: String, action: String)
}

type AsyncEntry(value) {
  AsyncEntry(key: String, state: AsyncState(value))
}

/// Async runtime state.
pub opaque type Runtime(value) {
  Runtime(connected: Bool, next_ref: Int, entries_rev: List(AsyncEntry(value)))
}

/// Build a runtime.
pub fn new(connected: Bool) -> Runtime(value) {
  Runtime(connected: connected, next_ref: 1, entries_rev: [])
}

/// Connection status.
pub fn connected(runtime: Runtime(value)) -> Bool {
  runtime.connected
}

/// Start one async assignment.
///
/// - when connected: enters `Loading(ref)`
/// - when disconnected: enters `PendingDisconnected`
pub fn assign_async(
  runtime: Runtime(value),
  key: String,
) -> #(Runtime(value), AsyncState(value)) {
  case runtime.connected {
    True -> {
      let ref = runtime.next_ref
      let state = Loading(ref: ref)
      let entries_rev = put_entry(runtime.entries_rev, key, state)
      #(Runtime(..runtime, next_ref: ref + 1, entries_rev: entries_rev), state)
    }

    False -> {
      let state = PendingDisconnected
      let entries_rev = put_entry(runtime.entries_rev, key, state)
      #(Runtime(..runtime, entries_rev: entries_rev), state)
    }
  }
}

/// Mark runtime connected and start pending async assignments.
pub fn connect(runtime: Runtime(value)) -> Runtime(value) {
  let #(next_ref, entries_rev) =
    start_pending(list.reverse(runtime.entries_rev), runtime.next_ref, [])

  Runtime(connected: True, next_ref: next_ref, entries_rev: entries_rev)
}

/// Mark runtime disconnected.
pub fn disconnect(runtime: Runtime(value)) -> Runtime(value) {
  Runtime(..runtime, connected: False)
}

/// Resolve one async assignment with a success value.
pub fn succeed(
  runtime: Runtime(value),
  key: String,
  value: value,
) -> #(Runtime(value), Result(Nil, AsyncError)) {
  transition(runtime, key, "succeed", fn(state) {
    case state {
      Loading(_) -> Ok(Succeeded(value: value))
      _ ->
        Error(InvalidTransition(
          key: key,
          state: state_label(state),
          action: "succeed",
        ))
    }
  })
}

/// Resolve one async assignment with a failure.
pub fn fail(
  runtime: Runtime(value),
  key: String,
  reason: String,
) -> #(Runtime(value), Result(Nil, AsyncError)) {
  transition(runtime, key, "fail", fn(state) {
    case state {
      Loading(_) -> Ok(Failed(reason: reason))
      _ ->
        Error(InvalidTransition(
          key: key,
          state: state_label(state),
          action: "fail",
        ))
    }
  })
}

/// Cancel one async assignment.
pub fn cancel(
  runtime: Runtime(value),
  key: String,
  reason: String,
) -> #(Runtime(value), Result(Nil, AsyncError)) {
  transition(runtime, key, "cancel", fn(state) {
    case state {
      PendingDisconnected -> Ok(Cancelled(reason: reason))
      Loading(_) -> Ok(Cancelled(reason: reason))
      _ ->
        Error(InvalidTransition(
          key: key,
          state: state_label(state),
          action: "cancel",
        ))
    }
  })
}

/// Lookup current state for one task key.
pub fn state(
  runtime: Runtime(value),
  key: String,
) -> Option(AsyncState(value)) {
  find_state(runtime.entries_rev, key)
}

/// Stable state label for logs and assertions.
pub fn state_label(state: AsyncState(value)) -> String {
  case state {
    Idle -> "idle"
    PendingDisconnected -> "pending_disconnected"
    Loading(_) -> "loading"
    Succeeded(_) -> "succeeded"
    Failed(_) -> "failed"
    Cancelled(_) -> "cancelled"
  }
}

/// Stable error string for assertions and logs.
pub fn error_to_string(error: AsyncError) -> String {
  case error {
    UnknownTask(key) -> "unknown_task:" <> key
    InvalidTransition(key, state, action) ->
      "invalid_transition:" <> key <> ":" <> state <> ":" <> action
  }
}

fn transition(
  runtime: Runtime(value),
  key: String,
  _action: String,
  with: fn(AsyncState(value)) -> Result(AsyncState(value), AsyncError),
) -> #(Runtime(value), Result(Nil, AsyncError)) {
  case update_state(runtime.entries_rev, key, with, []) {
    Error(error) -> #(runtime, Error(error))
    Ok(entries_rev) -> #(Runtime(..runtime, entries_rev: entries_rev), Ok(Nil))
  }
}

fn update_state(
  entries_rev: List(AsyncEntry(value)),
  key: String,
  with: fn(AsyncState(value)) -> Result(AsyncState(value), AsyncError),
  seen_rev: List(AsyncEntry(value)),
) -> Result(List(AsyncEntry(value)), AsyncError) {
  case entries_rev {
    [] -> Error(UnknownTask(key: key))
    [entry, ..rest] ->
      case entry.key == key {
        False -> update_state(rest, key, with, [entry, ..seen_rev])
        True ->
          case with(entry.state) {
            Error(error) -> Error(error)
            Ok(state) ->
              Ok(
                reverse_into(seen_rev, [
                  AsyncEntry(..entry, state: state),
                  ..rest
                ]),
              )
          }
      }
  }
}

fn put_entry(
  entries_rev: List(AsyncEntry(value)),
  key: String,
  state: AsyncState(value),
) -> List(AsyncEntry(value)) {
  case entries_rev {
    [] -> [AsyncEntry(key: key, state: state)]
    [entry, ..rest] ->
      case entry.key == key {
        True -> [AsyncEntry(..entry, state: state), ..rest]
        False -> [entry, ..put_entry(rest, key, state)]
      }
  }
}

fn start_pending(
  entries: List(AsyncEntry(value)),
  next_ref: Int,
  entries_rev: List(AsyncEntry(value)),
) -> #(Int, List(AsyncEntry(value))) {
  case entries {
    [] -> #(next_ref, entries_rev)

    [entry, ..rest] ->
      case entry.state {
        PendingDisconnected ->
          start_pending(rest, next_ref + 1, [
            AsyncEntry(..entry, state: Loading(ref: next_ref)),
            ..entries_rev
          ])

        _ -> start_pending(rest, next_ref, [entry, ..entries_rev])
      }
  }
}

fn find_state(
  entries_rev: List(AsyncEntry(value)),
  key: String,
) -> Option(AsyncState(value)) {
  case entries_rev {
    [] -> None
    [entry, ..rest] ->
      case entry.key == key {
        True -> Some(entry.state)
        False -> find_state(rest, key)
      }
  }
}

fn reverse_into(left: List(a), right: List(a)) -> List(a) {
  case left {
    [] -> right
    [entry, ..rest] -> reverse_into(rest, [entry, ..right])
  }
}