src/voauth.gleam

//// OAuth2 access-token vault with proactive refresh and bounded retry.
////
//// The vault holds the current access token, refreshes it before it
//// expires, retries transient failures, and gives up with a typed
//// error when the refresh token has been revoked. It does not perform
//// the OAuth flow itself; you provide a `Refresh` callback that talks
//// to your provider's token endpoint.
////
//// One vault per service. To handle multiple providers, start one
//// `Vault` per provider and supervise them with your application's
//// own supervisor.
////
//// A vault always starts without a token. Install one with `set_token`
//// after the user completes the OAuth flow, or after rehydrating from
//// durable storage at boot.
////
//// See the README for a quickstart.

import gleam/dynamic/decode
import gleam/erlang/process.{type Subject, type Timer}
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/otp/actor
import gleam/otp/supervision
import gleam/result
import gleam/string
import logging

// =====================================================
// Public types
// =====================================================

/// A long-lived process holding the access token for one OAuth service.
/// Returned by `start`. Pass it through your application's context.
pub opaque type Vault {
  Vault(subject: Subject(Message), call_timeout_ms: Int)
}

/// Returned by a `Refresh` callback. The variant tells the vault
/// whether to retry the failure or give up. Application code can
/// pattern-match on `RefreshUnauthorized` to drive a "please
/// reconnect" UI.
pub type RefreshError {
  /// Transient failure — the vault will retry with backoff.
  /// Network drop, 5xx, timeout, parse error.
  RefreshRetryable(reason: String)

  /// The refresh token has been rejected. The user must reauthorise.
  /// OAuth2 `invalid_grant` / `invalid_token`, revoked credentials.
  RefreshUnauthorized(reason: String)
}

/// Returned by the public vault API.
pub type VaultError {
  /// The provider's `Refresh` callback returned an error.
  RefreshFailed(RefreshError)

  /// No token to refresh. Call `set_token` to install one, or check
  /// that your provider issued a `refresh_token` (some require an
  /// `offline_access` scope).
  NoRefreshToken

  /// The vault's actor failed to start. `reason` is the underlying
  /// failure description.
  StartError(reason: String)
}

/// An OAuth2 token as returned by an authorisation or refresh
/// endpoint. `expires_in` is in seconds, as on the wire.
pub type Token {
  Token(
    access_token: String,
    expires_in: Int,
    refresh_token: Option(String),
    scope: String,
    token_type: String,
  )
}

/// What an OAuth2 refresh endpoint may return. Per RFC 6749 §5.1/§6
/// only `access_token` is required; the rest are optional and carry
/// forward from the previous token via `merge_response`.
pub type RefreshResponse {
  RefreshResponse(
    access_token: String,
    expires_in: Option(Int),
    refresh_token: Option(String),
    scope: Option(String),
    token_type: Option(String),
  )
}

/// Provider-specific refresh-token grant. The vault calls this when
/// the cached token expires or a proactive refresh fires.
pub type Refresh =
  fn(String) -> Result(RefreshResponse, RefreshError)

/// Hook fired after every successful refresh, typically to persist
/// the new token. Errors are logged and otherwise ignored; the vault
/// keeps running.
pub type OnRefresh =
  fn(Token) -> Result(Nil, String)

/// Configuration for one vault.
///
/// Construct with `config(refresh:)` and tweak with the `with_*`
/// setters. The type is opaque so that future settings stay
/// non-breaking.
pub opaque type Config {
  Config(
    refresh: Refresh,
    on_refresh: Option(OnRefresh),
    call_timeout_ms: Int,
    init_timeout_ms: Int,
    refresh_at_percent: Int,
    min_refresh_delay_ms: Int,
    retry_backoff_ms: List(Int),
  )
}

// =====================================================
// Public API: Config builder
// =====================================================

/// Build a `Config` with defaults. The provider-specific `Refresh`
/// callback is the only required argument.
///
/// Defaults:
/// - `on_refresh`: `None`
/// - `call_timeout_ms`: 30_000 — must exceed worst-case `Refresh`
///   HTTP latency.
/// - `init_timeout_ms`: 1_000
/// - `refresh_at_percent`: 80 — proactive refresh at 80% of `expires_in`.
/// - `min_refresh_delay_ms`: 60_000 — floor on the proactive delay.
/// - `retry_backoff_ms`: `[30_000, 60_000, 120_000]` (30s/1m/2m).
pub fn config(refresh refresh: Refresh) -> Config {
  Config(
    refresh:,
    on_refresh: None,
    call_timeout_ms: 30_000,
    init_timeout_ms: 1000,
    refresh_at_percent: 80,
    min_refresh_delay_ms: 60_000,
    retry_backoff_ms: [30_000, 60_000, 120_000],
  )
}

/// Persist tokens after every successful refresh. Runs inside the
/// vault's mailbox; keep it fast.
pub fn with_on_refresh(config: Config, callback: OnRefresh) -> Config {
  Config(..config, on_refresh: Some(callback))
}

/// Timeout (ms) for synchronous calls into the vault. Must exceed
/// worst-case `Refresh` HTTP latency, because `get_token` blocks on
/// a refresh when the cached token has expired.
pub fn with_call_timeout_ms(config: Config, ms: Int) -> Config {
  Config(..config, call_timeout_ms: ms)
}

/// Timeout (ms) for the actor's initialise phase.
pub fn with_init_timeout_ms(config: Config, ms: Int) -> Config {
  Config(..config, init_timeout_ms: ms)
}

/// Proactive refresh fires at this percent of `expires_in`. Default 80.
pub fn with_refresh_at_percent(config: Config, percent: Int) -> Config {
  Config(..config, refresh_at_percent: percent)
}

/// Floor (ms) on the proactive delay. Guards against providers that
/// hand out very short `expires_in` values.
pub fn with_min_refresh_delay_ms(config: Config, ms: Int) -> Config {
  Config(..config, min_refresh_delay_ms: ms)
}

/// Backoff schedule for retrying a failed scheduled refresh, indexed
/// by failed-attempt number. `[]` disables retries.
pub fn with_retry_backoff_ms(config: Config, schedule: List(Int)) -> Config {
  Config(..config, retry_backoff_ms: schedule)
}

// =====================================================
// Decoders / merge helper
// =====================================================

/// Decoder for an OAuth2 token JSON object.
pub fn token_decoder() -> decode.Decoder(Token) {
  use access_token <- decode.field("access_token", decode.string)
  use expires_in <- decode.field("expires_in", decode.int)
  use refresh_token <- decode.field(
    "refresh_token",
    decode.optional(decode.string),
  )
  use scope <- decode.field("scope", decode.string)
  use token_type <- decode.field("token_type", decode.string)
  decode.success(Token(
    access_token:,
    expires_in:,
    refresh_token:,
    scope:,
    token_type:,
  ))
}

/// Decoder for a refresh response. Fields other than `access_token`
/// are optional per RFC 6749 §6 and decode to `None` when absent.
pub fn refresh_response_decoder() -> decode.Decoder(RefreshResponse) {
  use access_token <- decode.field("access_token", decode.string)
  use expires_in <- decode.optional_field(
    "expires_in",
    None,
    decode.optional(decode.int),
  )
  use refresh_token <- decode.optional_field(
    "refresh_token",
    None,
    decode.optional(decode.string),
  )
  use scope <- decode.optional_field(
    "scope",
    None,
    decode.optional(decode.string),
  )
  use token_type <- decode.optional_field(
    "token_type",
    None,
    decode.optional(decode.string),
  )
  decode.success(RefreshResponse(
    access_token:,
    expires_in:,
    refresh_token:,
    scope:,
    token_type:,
  ))
}

/// Merge a refresh response onto the previous token, carrying
/// forward any field the server omitted.
pub fn merge_response(previous: Token, resp: RefreshResponse) -> Token {
  Token(
    access_token: resp.access_token,
    expires_in: option.unwrap(resp.expires_in, previous.expires_in),
    refresh_token: option.or(resp.refresh_token, previous.refresh_token),
    scope: option.unwrap(resp.scope, previous.scope),
    token_type: option.unwrap(resp.token_type, previous.token_type),
  )
}

// =====================================================
// Public API: lifecycle
// =====================================================

/// Start a vault. The vault begins without a token; install one with
/// `set_token` before calling `get_token` or `refresh_now`.
pub fn start(config: Config) -> Result(Vault, VaultError) {
  do_start(config)
  |> result.map(fn(started) { started.data })
  |> result.map_error(fn(e) { StartError(string.inspect(e)) })
}

/// Build a child specification for a `gleam_otp` supervisor. On
/// restart the supervisor calls `start` with the same `config`. To
/// rehydrate a persisted token on restart, write your own
/// `supervision.worker` that reads from durable storage and calls
/// `start` and `set_token`.
pub fn supervised(config: Config) -> supervision.ChildSpecification(Vault) {
  supervision.worker(fn() { do_start(config) })
}

/// Return the current valid access token. Refreshes synchronously if
/// the cached token has expired. Returns `NoRefreshToken` if no token
/// has been installed yet.
pub fn get_token(vault: Vault) -> Result(String, VaultError) {
  process.call(vault.subject, vault.call_timeout_ms, GetToken)
}

/// Force a refresh now, regardless of remaining validity. Returns
/// `NoRefreshToken` if no token has been installed yet.
pub fn refresh_now(vault: Vault) -> Result(Nil, VaultError) {
  process.call(vault.subject, vault.call_timeout_ms, RefreshNow)
}

/// Install or replace the vault's token. Schedules a fresh proactive
/// refresh. Use it after the OAuth flow completes, or after a
/// re-authorisation hands you a new token.
pub fn set_token(vault: Vault, token: Token) -> Nil {
  process.call(vault.subject, vault.call_timeout_ms, fn(reply) {
    SetToken(reply, token)
  })
}

// =====================================================
// Internal: messages, state, dispatcher
// =====================================================

type Message {
  GetToken(reply: Subject(Result(String, VaultError)))
  RefreshNow(reply: Subject(Result(Nil, VaultError)))
  ScheduledRefresh(attempt: Int)
  SetToken(reply: Subject(Nil), token: Token)
}

type State {
  State(
    config: Config,
    token: Option(Token),
    created_at_ms: Int,
    expires_in_ms: Int,
    timer: Option(Timer),
    self: Subject(Message),
  )
}

fn do_start(config: Config) -> Result(actor.Started(Vault), actor.StartError) {
  let initialise = fn(self) {
    let state =
      State(
        config:,
        token: None,
        created_at_ms: 0,
        expires_in_ms: 0,
        timer: None,
        self:,
      )
    actor.initialised(state)
    |> actor.returning(Vault(self, config.call_timeout_ms))
    |> Ok
  }

  actor.new_with_initialiser(config.init_timeout_ms, initialise)
  |> actor.on_message(handle_message)
  |> actor.start
}

fn handle_message(state: State, message: Message) -> actor.Next(State, Message) {
  case message {
    GetToken(reply:) -> handle_get_token(reply, state)
    RefreshNow(reply:) -> handle_refresh_now(reply, state)
    ScheduledRefresh(attempt:) -> handle_scheduled_refresh(attempt, state)
    SetToken(reply:, token:) -> handle_set_token(reply, token, state)
  }
}

fn handle_get_token(
  reply: Subject(Result(String, VaultError)),
  state: State,
) -> actor.Next(State, Message) {
  case state.token {
    None -> {
      process.send(reply, Error(NoRefreshToken))
      actor.continue(state)
    }
    Some(token) -> {
      let elapsed = monotonic_ms() - state.created_at_ms
      case elapsed < state.expires_in_ms {
        True -> {
          process.send(reply, Ok(token.access_token))
          actor.continue(state)
        }
        False -> {
          let #(new_state, result) = attempt_refresh(state)
          let reply_value = case result {
            Ok(_) -> {
              let assert Some(t) = new_state.token
              Ok(t.access_token)
            }
            Error(err) -> Error(err)
          }
          process.send(reply, reply_value)
          actor.continue(new_state)
        }
      }
    }
  }
}

fn handle_refresh_now(
  reply: Subject(Result(Nil, VaultError)),
  state: State,
) -> actor.Next(State, Message) {
  case state.token {
    None -> {
      process.send(reply, Error(NoRefreshToken))
      actor.continue(state)
    }
    Some(_) -> {
      let #(new_state, result) = attempt_refresh(state)
      process.send(reply, result)
      actor.continue(new_state)
    }
  }
}

fn handle_scheduled_refresh(
  attempt: Int,
  state: State,
) -> actor.Next(State, Message) {
  // The timer that scheduled this message has fired; clear the
  // reference so `state.timer = Some(_)` always means "a timer is
  // pending".
  let state = State(..state, timer: None)
  case state.token {
    Some(_) -> {
      let #(new_state, result) = attempt_refresh(state)
      case result {
        Ok(_) -> actor.continue(new_state)
        Error(err) -> handle_refresh_failure(attempt, err, new_state)
      }
    }
    None -> actor.continue(state)
  }
}

fn handle_set_token(
  reply: Subject(Nil),
  token: Token,
  state: State,
) -> actor.Next(State, Message) {
  let state = cancel_timer_in_state(state)
  let timer = schedule_proactive(state.self, state.config, token.expires_in)
  let new_state =
    State(
      ..state,
      token: Some(token),
      created_at_ms: monotonic_ms(),
      expires_in_ms: token.expires_in * 1000,
      timer: Some(timer),
    )
  process.send(reply, Nil)
  actor.continue(new_state)
}

fn handle_refresh_failure(
  attempt: Int,
  err: VaultError,
  state: State,
) -> actor.Next(State, Message) {
  case err {
    RefreshFailed(RefreshUnauthorized(_)) | NoRefreshToken | StartError(_) -> {
      logging.log(
        logging.Warning,
        "voauth: refresh giving up (not retryable): " <> describe_error(err),
      )
      actor.continue(state)
    }
    RefreshFailed(RefreshRetryable(_)) ->
      case schedule_retry(state.self, state.config.retry_backoff_ms, attempt) {
        Some(timer) -> actor.continue(State(..state, timer: Some(timer)))
        None -> {
          logging.log(
            logging.Warning,
            "voauth: refresh giving up after "
              <> int.to_string(attempt + 1)
              <> " attempts: "
              <> describe_error(err),
          )
          actor.continue(state)
        }
      }
  }
}

// =====================================================
// Internal: refresh mechanics
// =====================================================

fn attempt_refresh(state: State) -> #(State, Result(Nil, VaultError)) {
  let state = cancel_timer_in_state(state)

  case state.token {
    Some(current_token) ->
      case current_token.refresh_token {
        Some(refresh_token) ->
          case state.config.refresh(refresh_token) {
            Ok(resp) -> {
              let token = merge_response(current_token, resp)
              case run_on_refresh(state.config, token) {
                Ok(Nil) -> Nil
                Error(reason) ->
                  logging.log(
                    logging.Error,
                    "voauth: on_refresh callback failed: " <> reason,
                  )
              }
              let timer =
                schedule_proactive(state.self, state.config, token.expires_in)
              let new_state =
                State(
                  ..state,
                  token: Some(token),
                  created_at_ms: monotonic_ms(),
                  expires_in_ms: token.expires_in * 1000,
                  timer: Some(timer),
                )
              #(new_state, Ok(Nil))
            }
            Error(refresh_error) -> #(
              state,
              Error(RefreshFailed(refresh_error)),
            )
          }
        None -> #(state, Error(NoRefreshToken))
      }
    None -> #(state, Error(NoRefreshToken))
  }
}

fn run_on_refresh(config: Config, token: Token) -> Result(Nil, String) {
  case config.on_refresh {
    None -> Ok(Nil)
    Some(callback) -> callback(token)
  }
}

fn describe_error(err: VaultError) -> String {
  case err {
    RefreshFailed(RefreshRetryable(reason)) -> "retryable: " <> reason
    RefreshFailed(RefreshUnauthorized(reason)) -> "unauthorized: " <> reason
    NoRefreshToken -> "no refresh token"
    StartError(reason) -> "vault start error: " <> reason
  }
}

// =====================================================
// Internal: timers
// =====================================================

/// Look up the backoff delay for the given failed-attempt number.
/// Returns `None` if the schedule is exhausted (caller should give up).
fn retry_backoff_at(schedule: List(Int), failed_attempt: Int) -> Option(Int) {
  schedule
  |> list.drop(failed_attempt)
  |> list.first
  |> option.from_result
}

fn schedule_proactive(
  self: Subject(Message),
  config: Config,
  expires_in_seconds: Int,
) -> Timer {
  let delay_ms = expires_in_seconds * 1000 * config.refresh_at_percent / 100
  let delay_ms = int.max(delay_ms, config.min_refresh_delay_ms)
  process.send_after(self, delay_ms, ScheduledRefresh(0))
}

/// Schedule the next retry attempt after a failed scheduled refresh.
/// Returns `None` if the backoff schedule is exhausted.
fn schedule_retry(
  self: Subject(Message),
  schedule: List(Int),
  failed_attempt: Int,
) -> Option(Timer) {
  case retry_backoff_at(schedule, failed_attempt) {
    Some(delay) ->
      Some(process.send_after(self, delay, ScheduledRefresh(failed_attempt + 1)))
    None -> None
  }
}

fn cancel_timer_in_state(state: State) -> State {
  case state.timer {
    Some(timer) -> {
      process.cancel_timer(timer)
      State(..state, timer: None)
    }
    None -> state
  }
}

@external(erlang, "voauth_ffi", "monotonic_ms")
fn monotonic_ms() -> Int