///| OAuth 2.0 for headless servers.
///
/// Microsoft and Google no longer accept a username and password over IMAP, so
/// those accounts need OAuth. On a server there is no browser to redirect
/// back to, which is exactly the case the device authorization grant
/// (RFC 8628) covers: we ask the provider for a short code, print a URL and
/// the code, and poll until the user approves it from their phone or laptop.
///
/// The refresh token is the long-lived secret; the access token is derived
/// from it on demand and kept in memory. Tokens never reach the logs.

///|
pub enum AuthKind {
  PasswordLogin
  OAuth2Login
}

///|
/// Resolved OAuth endpoints and settings for one account.
pub struct OAuthSettings {
  provider : String
  client_id : String
  tenant : String
  scope : String
  device_endpoint : String
  token_endpoint : String
  token_file : String
}

///|
/// The RFC 8628 device authorization response.
pub struct DeviceAuthorization {
  device_code : String
  user_code : String
  verification_uri : String
  verification_uri_complete : String
  interval_ms : Int32
  expires_in_s : Int32
  message : String
}

///|
/// An access token and the refresh token that replaces it.
pub struct TokenSet {
  access_token : String
  refresh_token : String
  expires_at_ms : Int64
}

///|
/// Access tokens live for about an hour; treat them as stale a little early so
/// a request never starts with a token that expires mid-handshake.
let token_slack_ms : Int64 = 60_000L

///|
/// Microsoft documents `outlook.office.com` for the IMAP scope; Google uses the
/// single `mail.google.com` scope. Both are overridable from the config.
pub fn oauth_provider_defaults(
  provider : String,
  tenant : String,
) -> (String, String, String) raise MailsError {
  match provider {
    "microsoft" => {
      let tenant = if is_blank(tenant) { "common" } else { trim(tenant) }
      (
        "https://login.microsoftonline.com/" +
        tenant +
        "/oauth2/v2.0/devicecode",
        "https://login.microsoftonline.com/" + tenant + "/oauth2/v2.0/token",
        "offline_access https://outlook.office.com/IMAP.AccessAsUser.All",
      )
    }
    "google" =>
      (
        "https://oauth2.googleapis.com/device/code", "https://oauth2.googleapis.com/token",
        "https://mail.google.com/",
      )
    "custom" =>
      raise MailsError(
        "the 'custom' OAuth provider needs oauth_device_endpoint, " +
        "oauth_token_endpoint and oauth_scope",
      )
    other => raise MailsError("unknown oauth_provider '" + other + "'")
  }
}

///|
/// Percent-encode one form value (RFC 3986 unreserved characters stay as-is).
fn form_escape(text : String) -> String {
  let out = StringBuilder()
  for byte in string_to_bytes(text) {
    let value = byte.to_int()
    let plain = (value >= 0x41 && value <= 0x5A) ||
      (value >= 0x61 && value <= 0x7A) ||
      (value >= 0x30 && value <= 0x39) ||
      value == 0x2D ||
      value == 0x2E ||
      value == 0x5F ||
      value == 0x7E ||
      value == 0x20
    if plain {
      if value == 0x20 {
        out.write_char('+')
      } else {
        out.write_char(value.unsafe_to_char())
      }
    } else {
      out.write_char('%')
      out.write_string(hex_byte(value))
    }
  }
  out.to_string()
}

///|
fn hex_byte(value : Int32) -> String {
  let digits : Array[Char] = [
    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F',
  ]
  let out = StringBuilder()
  out.write_char(digits[(value >> 4) & 0xF])
  out.write_char(digits[value & 0xF])
  out.to_string()
}

///|
fn form_encode(pairs : Array[(String, String)]) -> String {
  let out = StringBuilder()
  let mut first = true
  for pair in pairs {
    let (key, value) = pair
    if !first {
      out.write_char('&')
    }
    first = false
    out.write_string(form_escape(key))
    out.write_char('=')
    out.write_string(form_escape(value))
  }
  out.to_string()
}

///|
/// POST an `application/x-www-form-urlencoded` body and return the decoded
/// JSON. A non-200 response is turned into an error that carries the
/// provider's own description, which is far more useful than a bare status.
async fn post_form(
  url : String,
  pairs : Array[(String, String)],
) -> Json raise MailsError {
  let headers : Map[@http.CaseInsensitiveString, String] = Map([])
  headers.set(
    @http.CaseInsensitiveString("Content-Type"),
    "application/x-www-form-urlencoded",
  )
  headers.set(@http.CaseInsensitiveString("Accept"), "application/json")
  let body = form_encode(pairs)
  let (response, data) = @http.post(url, body, headers~) catch {
    err => raise MailsError("cannot reach " + url + ": " + err.to_string())
  }
  let text = data.text() catch {
    _ => raise MailsError("cannot read the response from " + url)
  }
  let json = @json.parse(text) catch {
    _ =>
      raise MailsError(
        "unexpected response from " +
        url +
        " (HTTP " +
        response.code.to_string() +
        "): " +
        truncate_chars(text, 200),
      )
  }
  if response.code != 200 {
    let detail = match json_field_string(json, "error_description") {
      Some(text) => text
      None =>
        match json_field_string(json, "error") {
          Some(code) => code
          None => truncate_chars(text, 200)
        }
    }
    raise MailsError(
      "OAuth endpoint " +
      url +
      " returned HTTP " +
      response.code.to_string() +
      ": " +
      detail,
    )
  }
  json
}

///|
/// Like `post_form`, but keeps the error as a message so the polling loop can
/// inspect it (the device grant reports "authorization_pending" as an error).
async fn post_form_result(
  url : String,
  pairs : Array[(String, String)],
) -> Result[Json, String] noraise {
  let json = post_form(url, pairs) catch {
    err => return Err(error_message(err))
  }
  Ok(json)
}

///|
fn json_field(json : Json, name : String) -> Json? {
  match json {
    Object(fields) => fields.get(name)
    _ => None
  }
}

///|
fn json_field_string(json : Json, name : String) -> String? {
  match json_field(json, name) {
    Some(String(text)) => Some(text)
    _ => None
  }
}

///|
fn json_field_int(json : Json, name : String) -> Int32? {
  match json_field(json, name) {
    Some(Number(value, ..)) => Some(value.to_int())
    _ => None
  }
}

///|
/// Ask the provider for a device code and the URL the user has to visit.
pub async fn request_device_code(
  settings : OAuthSettings,
) -> DeviceAuthorization raise MailsError {
  if is_blank(settings.client_id) {
    raise MailsError(
      "this account needs oauth_client_id (create an app registration first)",
    )
  }
  let json = post_form(settings.device_endpoint, [
    ("client_id", settings.client_id),
    ("scope", settings.scope),
  ])
  let device_code = match json_field_string(json, "device_code") {
    Some(value) if !is_blank(value) => value
    _ => raise MailsError("the device code response has no device_code")
  }
  let user_code = match json_field_string(json, "user_code") {
    Some(value) if !is_blank(value) => value
    _ => raise MailsError("the device code response has no user_code")
  }
  let verification_uri = match json_field_string(json, "verification_uri") {
    Some(value) if !is_blank(value) => value
    None =>
      match json_field_string(json, "verification_url") {
        Some(value) if !is_blank(value) => value
        _ =>
          raise MailsError("the device code response has no verification_uri")
      }
    Some(_) =>
      raise MailsError("the device code response has no verification_uri")
  }
  let verification_uri_complete = match
    json_field_string(json, "verification_uri_complete") {
    Some(value) => value
    None => ""
  }
  let expires_in_s = match json_field_int(json, "expires_in") {
    Some(value) if value > 0 => value
    _ => 900
  }
  let interval_s = match json_field_int(json, "interval") {
    Some(value) if value > 0 => value
    _ => 5
  }
  let message = match json_field_string(json, "message") {
    Some(value) => value
    None => ""
  }
  {
    device_code,
    user_code,
    verification_uri,
    verification_uri_complete,
    interval_ms: interval_s * 1000,
    expires_in_s,
    message,
  }
}

///|
fn token_from_json(json : Json) -> TokenSet raise MailsError {
  let access_token = match json_field_string(json, "access_token") {
    Some(value) if !is_blank(value) => value
    _ => raise MailsError("the token response has no access_token")
  }
  let refresh_token = match json_field_string(json, "refresh_token") {
    Some(value) => value
    None => ""
  }
  let expires_in = match json_field_int(json, "expires_in") {
    Some(value) if value > 0 => value
    _ => 3600
  }
  {
    access_token,
    refresh_token,
    expires_at_ms: @async.now() + expires_in.to_int64() * 1000L,
  }
}

///|
/// Poll the token endpoint until the user approves the device code.
///
/// `on_pending` is called after every poll so the caller can report progress
/// without knowing the polling rules.
pub async fn poll_device_token(
  settings : OAuthSettings,
  authorization : DeviceAuthorization,
  on_pending : () -> Unit,
) -> TokenSet raise MailsError {
  let deadline = @async.now() + authorization.expires_in_s.to_int64() * 1000L
  let mut interval_ms = if authorization.interval_ms > 0 {
    authorization.interval_ms
  } else {
    5_000
  }
  while @async.now() < deadline {
    @async.sleep(interval_ms)
    let outcome = post_form_result(settings.token_endpoint, [
      ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
      ("device_code", authorization.device_code),
      ("client_id", settings.client_id),
    ])
    match outcome {
      Ok(json) => return token_from_json(json)
      Err(message) => {
        if message.contains("authorization_pending") {
          on_pending()
          continue
        }
        if message.contains("slow_down") {
          interval_ms = interval_ms + 5_000
          on_pending()
          continue
        }
        if message.contains("expired_token") {
          raise MailsError(
            "the device code expired before it was approved; run `mails login` again",
          )
        }
        if message.contains("authorization_declined") ||
          message.contains("access_denied") {
          raise MailsError("the sign-in request was declined")
        }
        raise MailsError(message)
      }
    }
  }
  raise MailsError("timed out waiting for the device code to be approved")
}

///|
/// Exchange a refresh token for a new access token.
pub async fn refresh_access_token(
  settings : OAuthSettings,
  refresh_token : String,
) -> TokenSet raise MailsError {
  let json = post_form(settings.token_endpoint, [
    ("grant_type", "refresh_token"),
    ("refresh_token", refresh_token),
    ("client_id", settings.client_id),
    ("scope", settings.scope),
  ])
  let refreshed = token_from_json(json)
  if is_blank(refreshed.refresh_token) {
    // Providers that do not rotate the refresh token omit it; keep the old one.
    { ..refreshed, refresh_token, }
  } else {
    refreshed
  }
}

///|
/// The token file holds the refresh token (never the access token), either
/// sealed with the configured key or, when no key is configured, in clear text
/// with mode 0600.
fn default_token_file(config_dir : String, account_name : String) -> String {
  config_dir + "/." + account_name + ".oauth"
}

///|
pub fn token_path(account : Account, config_dir : String) -> String {
  if is_blank(account.oauth_token_file) {
    default_token_file(config_dir, account.name)
  } else if account.oauth_token_file.has_prefix("/") {
    account.oauth_token_file
  } else {
    config_dir + "/" + account.oauth_token_file
  }
}

///|
fn json_escape(text : String) -> String {
  let out = StringBuilder()
  for c in text {
    match c {
      '"' => out.write_string("\\\"")
      '\\' => out.write_string("\\\\")
      '\n' => out.write_string("\\n")
      '\r' => out.write_string("\\r")
      '\t' => out.write_string("\\t")
      _ => out.write_char(c)
    }
  }
  out.to_string()
}

///|
/// Persist the refresh token for `account`, sealed when a key is available.
pub async fn save_refresh_token(
  path : String,
  account : Account,
  refresh_token : String,
  key : String,
) -> Unit raise MailsError {
  let stored = if is_blank(key) {
    refresh_token
  } else {
    seal_secret(key, refresh_token) catch {
      err =>
        raise MailsError("cannot seal the refresh token: " + error_message(err))
    }
  }
  let document = "{\"version\":1" +
    ",\"provider\":\"" +
    json_escape(account.oauth_provider) +
    "\"" +
    ",\"client_id\":\"" +
    json_escape(account.oauth_client_id) +
    "\"" +
    ",\"refresh_token\":\"" +
    json_escape(stored) +
    "\"}\n"
  @fs.write_file(path, string_to_bytes(document), permission=0o600) catch {
    err => raise MailsError("cannot write " + path + ": " + error_message(err))
  }
  @fs.chmod(path, 0o600) catch {
    _ => ()
  }
}

///|
/// Read the refresh token previously stored by `mails login`.
pub async fn load_refresh_token(
  path : String,
  key : String,
) -> String? raise MailsError {
  let exists = @fs.exists(path) catch { _ => false }
  if !exists {
    return None
  }
  let text = @fs.read_file(path).text() catch {
    err => raise MailsError("cannot read " + path + ": " + error_message(err))
  }
  let json = @json.parse(text) catch {
    _ => raise MailsError(path + " is not a mails token file")
  }
  match json_field_string(json, "refresh_token") {
    Some(value) if !is_blank(value) => {
      let token = resolve_secret(key, value) catch {
        err =>
          raise MailsError(
            "cannot decrypt the refresh token in " +
            path +
            ": " +
            error_message(err),
          )
      }
      Some(token)
    }
    _ => None
  }
}

///|
/// Cache of live access tokens, keyed by account name. The watch loop opens one
/// connection per mailbox, and every one of them needs the same token; without
/// this each reconnect would perform its own refresh round-trip.
let access_token_cache : Map[String, TokenSet] = Map([])

///|
pub fn forget_access_token(account_name : String) -> Unit {
  access_token_cache.remove(account_name)
}

///|
/// Return a usable access token, refreshing it when it is missing or stale.
/// The refresh token is re-saved when the provider rotates it.
pub async fn access_token_for(
  account : Account,
  config_dir : String,
  key : String,
) -> String raise MailsError {
  let settings = oauth_settings_for(account, config_dir)
  let now = @async.now()
  match access_token_cache.get(account.name) {
    Some(cached) =>
      if cached.expires_at_ms - token_slack_ms > now {
        return cached.access_token
      }
    None => ()
  }
  let path = token_path(account, config_dir)
  let refresh_token = match load_refresh_token(path, key) {
    Some(token) => token
    None =>
      raise MailsError(
        "account '" +
        account.name +
        "' has no stored OAuth token; run `mails login --account " +
        account.name +
        "`",
      )
  }
  // A refresh token can be revoked or expired; drop the cached access token so
  // the next attempt starts clean instead of retrying a dead credential.
  errdefer forget_access_token(account.name)
  let tokens = refresh_access_token(settings, refresh_token)
  if tokens.refresh_token != refresh_token {
    save_refresh_token(path, account, tokens.refresh_token, key) catch {
      _ => ()
    }
  }
  access_token_cache.set(account.name, tokens)
  tokens.access_token
}

///|
/// Build the OAuthSettings for an account, filling provider defaults.
pub fn oauth_settings_for(
  account : Account,
  config_dir : String,
) -> OAuthSettings raise MailsError {
  let provider = if is_blank(account.oauth_provider) {
    "microsoft"
  } else {
    trim(account.oauth_provider)
  }
  let (device_default, token_default, scope_default) = if provider == "custom" {
    ("", "", "")
  } else {
    oauth_provider_defaults(provider, account.oauth_tenant)
  }
  let device_endpoint = if is_blank(account.oauth_device_endpoint) {
    device_default
  } else {
    trim(account.oauth_device_endpoint)
  }
  let token_endpoint = if is_blank(account.oauth_token_endpoint) {
    token_default
  } else {
    trim(account.oauth_token_endpoint)
  }
  if is_blank(device_endpoint) {
    raise MailsError(
      "account '" +
      account.name +
      "' needs oauth_device_endpoint (provider '" +
      provider +
      "')",
    )
  }
  if is_blank(token_endpoint) {
    raise MailsError(
      "account '" +
      account.name +
      "' needs oauth_token_endpoint (provider '" +
      provider +
      "')",
    )
  }
  let scope = if is_blank(account.oauth_scope) {
    scope_default
  } else {
    trim(account.oauth_scope)
  }
  if is_blank(scope) {
    raise MailsError(
      "account '" +
      account.name +
      "' needs oauth_scope (provider '" +
      provider +
      "')",
    )
  }
  {
    provider,
    client_id: trim(account.oauth_client_id),
    tenant: trim(account.oauth_tenant),
    scope,
    device_endpoint,
    token_endpoint,
    token_file: token_path(account, config_dir),
  }
}

///|
/// Build the credential for an account: a resolved password, or a live OAuth2
/// access token refreshed on demand.
pub async fn credential_for(
  account : Account,
  config_dir : String,
  key : String,
) -> Credential raise MailsError {
  match account.auth {
    PasswordLogin => {
      if is_blank(account.password) {
        raise MailsError(
          "account '" +
          account.name +
          "' has no password; run `mails encrypt --account " +
          account.name +
          " --write`",
        )
      }
      Password(resolve_secret(key, account.password))
    }
    OAuth2Login => Bearer(access_token_for(account, config_dir, key))
  }
}