///| Configuration model and TOML loader.
///
/// `mails` reads exactly one configuration file, `mails.toml`, from the
/// directory holding the `mails` executable (override with `--config`). The
/// `.env` file holding the encryption key lives in that same directory unless
/// `general.env_file` says otherwise.

///|
pub let config_file_name : String = "mails.toml"

///|
pub enum MatchField {
  Subject
  Body
  From
  To
}

///|
pub fn MatchField::as_string(self : MatchField) -> String {
  match self {
    Subject => "subject"
    Body => "body"
    From => "from"
    To => "to"
  }
}

///|
pub struct Rule {
  name : String
  accounts : Array[String]
  mailboxes : Array[String]
  fields : Array[MatchField]
  keywords : Array[String]
  require_all : Bool
  ignore_case : Bool
  regex : Bool
  shell_command : String
  argv : Array[String]
  timeout_ms : Int32
  workdir : String
  body_chars : Int32
}

///|
pub struct Defaults {
  startup_sync : Bool
  idle_timeout_ms : Int32
  reconnect_ms : Int32
  max_body_bytes : Int32
  command_timeout_ms : Int32
  body_chars : Int32
}

///|
pub struct Account {
  name : String
  host : String
  port : Int32
  username : String
  password : String
  use_tls : Bool
  tls_verify : Bool
  auth : AuthKind
  oauth_provider : String
  oauth_client_id : String
  oauth_tenant : String
  oauth_scope : String
  oauth_device_endpoint : String
  oauth_token_endpoint : String
  oauth_token_file : String
  mailboxes : Array[String]
  rules : Array[Rule]
  startup_sync : Bool
  idle_timeout_ms : Int32
  reconnect_ms : Int32
  max_body_bytes : Int32
}

///|
pub struct Config {
  path : String
  dir : String
  env_file : String
  key_env : String
  defaults : Defaults
  rules : Array[Rule]
  accounts : Array[Account]
}

///|
pub fn default_defaults() -> Defaults {
  {
    startup_sync: false,
    idle_timeout_ms: 29 * 60_000,
    reconnect_ms: 30_000,
    max_body_bytes: 256 * 1024,
    command_timeout_ms: 30_000,
    body_chars: 4_000,
  }
}

///|
/// Directory containing the running executable.
pub async fn executable_dir() -> String raise MailsError {
  let argv = @env.args()
  if argv.length() == 0 {
    raise MailsError("cannot determine the location of the mails binary")
  }
  let exe = argv[0]
  let resolved = if exe.contains("/") {
    @fs.realpath(exe) catch {
      _ =>
        raise MailsError("cannot resolve the path of the mails binary: " + exe)
    }
  } else {
    let path = match @env.get_env_var("PATH") {
      Some(value) => value
      None => "/usr/account_rule/bin:/usr/bin:/bin"
    }
    let mut found = ""
    for dir in path.split(":") {
      let candidate = dir.to_owned() + "/" + exe
      let exists = @fs.exists(candidate) catch { _ => false }
      if exists {
        found = @fs.realpath(candidate) catch { _ => candidate }
        break
      }
    }
    if found == "" {
      raise MailsError("cannot find the mails binary on PATH")
    }
    found
  }
  match resolved.rev_find("/") {
    Some(index) => resolved[0:index].to_owned()
    None => "."
  }
}

///|
/// The config path used when `--config` is not passed.
pub async fn default_config_path() -> String raise MailsError {
  match @env.get_env_var("MAILS_CONFIG") {
    Some(path) if !is_blank(path) => trim(path)
    _ => executable_dir() + "/" + config_file_name
  }
}

///|
fn value_as_string(value : @toml.TomlValue) -> String? {
  match value {
    @toml.TomlString(text) => Some(text)
    _ => None
  }
}

///|
fn value_as_bool(value : @toml.TomlValue) -> Bool? {
  match value {
    @toml.TomlBoolean(flag) => Some(flag)
    _ => None
  }
}

///|
fn value_as_int(value : @toml.TomlValue) -> Int32? {
  match value {
    @toml.TomlInteger(number) => Some(number.to_int())
    _ => None
  }
}

///|
fn value_as_table(value : @toml.TomlValue) -> Map[String, @toml.TomlValue]? {
  match value {
    @toml.TomlTable(table) => Some(table)
    _ => None
  }
}

///|
fn value_as_array(value : @toml.TomlValue) -> Array[@toml.TomlValue]? {
  match value {
    @toml.TomlArray(items) => Some(items)
    _ => None
  }
}

///|
fn read_string(
  table : Map[String, @toml.TomlValue],
  key : String,
  what : String,
) -> String? raise MailsError {
  match table.get(key) {
    None => None
    Some(value) =>
      match value_as_string(value) {
        Some(text) => Some(text)
        None => raise MailsError(what + " must be a string")
      }
  }
}

///|
fn read_bool(
  table : Map[String, @toml.TomlValue],
  key : String,
  what : String,
) -> Bool? raise MailsError {
  match table.get(key) {
    None => None
    Some(value) =>
      match value_as_bool(value) {
        Some(flag) => Some(flag)
        None => raise MailsError(what + " must be true or false")
      }
  }
}

///|
fn read_int(
  table : Map[String, @toml.TomlValue],
  key : String,
  what : String,
) -> Int32? raise MailsError {
  match table.get(key) {
    None => None
    Some(value) =>
      match value_as_int(value) {
        Some(number) => Some(number)
        None => raise MailsError(what + " must be an integer")
      }
  }
}

///|
fn read_string_array(
  table : Map[String, @toml.TomlValue],
  key : String,
  what : String,
) -> Array[String]? raise MailsError {
  match table.get(key) {
    None => None
    Some(value) =>
      match value_as_array(value) {
        Some(items) => {
          let out : Array[String] = []
          for item in items {
            match value_as_string(item) {
              Some(text) => out.push(text)
              None => raise MailsError(what + " must be an array of strings")
            }
          }
          Some(out)
        }
        None => raise MailsError(what + " must be an array of strings")
      }
  }
}

///|
fn read_duration(
  table : Map[String, @toml.TomlValue],
  key : String,
  what : String,
) -> Int32? raise MailsError {
  match read_string(table, key, what) {
    None => None
    Some(text) =>
      match parse_duration_ms(text) {
        Some(ms) => {
          if ms < 0 {
            raise MailsError(what + " must not be negative")
          }
          Some(ms)
        }
        None =>
          raise MailsError(what + " is not a duration (try \"30s\", \"5m\")")
      }
  }
}

///|
fn table_field(
  table : Map[String, @toml.TomlValue],
  key : String,
  what : String,
) -> Map[String, @toml.TomlValue]? raise MailsError {
  match table.get(key) {
    None => None
    Some(value) =>
      match value_as_table(value) {
        Some(inner) => Some(inner)
        None => raise MailsError(what + " must be a table")
      }
  }
}

///|
fn array_of_tables(
  table : Map[String, @toml.TomlValue],
  key : String,
  what : String,
) -> Array[Map[String, @toml.TomlValue]]? raise MailsError {
  match table.get(key) {
    None => None
    Some(value) =>
      match value_as_array(value) {
        Some(items) => {
          let out : Array[Map[String, @toml.TomlValue]] = []
          for item in items {
            match value_as_table(item) {
              Some(inner) => out.push(inner)
              None => raise MailsError(what + " must be an array of tables")
            }
          }
          Some(out)
        }
        None => raise MailsError(what + " must be an array of tables")
      }
  }
}

///|
pub fn parse_match_field(text : String) -> MatchField? {
  match trim(text).to_lower() {
    "subject" | "title" => Some(Subject)
    "body" | "content" | "text" => Some(Body)
    "from" | "sender" => Some(From)
    "to" | "recipient" => Some(To)
    _ => None
  }
}

///|
fn parse_rule(
  table : Map[String, @toml.TomlValue],
  defaults : Defaults,
  index : Int32,
) -> Rule raise MailsError {
  let name = match read_string(table, "name", "rule name") {
    Some(text) if !is_blank(text) => trim(text)
    _ => "rule-" + (index + 1).to_string()
  }
  let what = "rule '" + name + "': "
  let keywords = match read_string_array(table, "keywords", what + "keywords") {
    Some(items) => {
      if items.length() == 0 {
        raise MailsError(what + "needs at least one keyword")
      }
      items
    }
    None =>
      match read_string(table, "keyword", what + "keyword") {
        Some(text) if !is_blank(text) => [trim(text)]
        _ => raise MailsError(what + "needs a 'keywords' array")
      }
  }
  let fields = match read_string_array(table, "fields", what + "fields") {
    Some(items) => {
      let out : Array[MatchField] = []
      for item in items {
        match parse_match_field(item) {
          Some(field) => out.push(field)
          None =>
            raise MailsError(
              what +
              "unknown field '" +
              item +
              "' (use subject, body, from or to)",
            )
        }
      }
      if out.length() == 0 {
        raise MailsError(what + "needs at least one field")
      }
      out
    }
    None => [Subject, Body]
  }
  let require_all = match read_string(table, "match", what + "match") {
    Some(text) =>
      match trim(text).to_lower() {
        "all" | "every" | "and" => true
        "any" | "or" => false
        _ => raise MailsError(what + "'match' must be 'any' or 'all'")
      }
    None =>
      match read_bool(table, "require_all", what + "require_all") {
        Some(flag) => flag
        None => false
      }
  }
  let ignore_case = match
    read_bool(table, "case_sensitive", what + "case_sensitive") {
    Some(flag) => !flag
    None => true
  }
  let regex = match read_bool(table, "regex", what + "regex") {
    Some(flag) => flag
    None => false
  }
  if regex {
    for keyword in keywords {
      let pattern = if ignore_case { "(?i:" + keyword + ")" } else { keyword }
      let compiled = @string.Regex::Regex(pattern) catch {
        _ =>
          raise MailsError(
            what + "invalid regular expression '" + keyword + "'",
          )
      }
      ignore(compiled)
    }
  }
  let shell_command = match read_string(table, "command", what + "command") {
    Some(text) => trim(text)
    None => ""
  }
  let argv = match read_string_array(table, "exec", what + "exec") {
    Some(items) => items
    None => []
  }
  if shell_command == "" && argv.length() == 0 {
    raise MailsError(what + "needs a 'command' string or an 'exec' array")
  }
  let timeout_ms = match read_duration(table, "timeout", what + "timeout") {
    Some(ms) => ms
    None => defaults.command_timeout_ms
  }
  let workdir = match read_string(table, "workdir", what + "workdir") {
    Some(text) => text
    None => ""
  }
  let body_chars = match read_int(table, "body_chars", what + "body_chars") {
    Some(number) => {
      if number < 0 {
        raise MailsError(what + "body_chars must not be negative")
      }
      number
    }
    None => defaults.body_chars
  }
  let accounts = match read_string_array(table, "accounts", what + "accounts") {
    Some(items) => items
    None => []
  }
  let mailboxes = match
    read_string_array(table, "mailboxes", what + "mailboxes") {
    Some(items) => items
    None => []
  }
  {
    name,
    accounts,
    mailboxes,
    fields,
    keywords,
    require_all,
    ignore_case,
    regex,
    shell_command,
    argv,
    timeout_ms,
    workdir,
    body_chars,
  }
}

///|
fn parse_rules(
  table : Map[String, @toml.TomlValue],
  defaults : Defaults,
  what : String,
) -> Array[Rule] raise MailsError {
  match array_of_tables(table, "rules", what) {
    Some(items) => {
      let out : Array[Rule] = []
      let mut index = 0
      for item in items {
        out.push(parse_rule(item, defaults, index))
        index = index + 1
      }
      out
    }
    None => []
  }
}

///|
fn parse_defaults(
  general : Map[String, @toml.TomlValue]?,
) -> Defaults raise MailsError {
  let base = default_defaults()
  match general {
    None => base
    Some(table) => {
      let startup_sync = match
        read_bool(table, "startup_sync", "general.startup_sync") {
        Some(flag) => flag
        None => base.startup_sync
      }
      let idle_timeout_ms = match
        read_duration(table, "idle_timeout", "general.idle_timeout") {
        Some(ms) => ms
        None => base.idle_timeout_ms
      }
      let reconnect_ms = match
        read_duration(table, "reconnect_delay", "general.reconnect_delay") {
        Some(ms) => ms
        None => base.reconnect_ms
      }
      let max_body_bytes = match
        read_int(table, "max_body_bytes", "general.max_body_bytes") {
        Some(number) => {
          if number < 0 {
            raise MailsError("general.max_body_bytes must not be negative")
          }
          number
        }
        None => base.max_body_bytes
      }
      let command_timeout_ms = match
        read_duration(table, "command_timeout", "general.command_timeout") {
        Some(ms) => ms
        None => base.command_timeout_ms
      }
      let body_chars = match
        read_int(table, "body_chars", "general.body_chars") {
        Some(number) => {
          if number < 0 {
            raise MailsError("general.body_chars must not be negative")
          }
          number
        }
        None => base.body_chars
      }
      {
        startup_sync,
        idle_timeout_ms,
        reconnect_ms,
        max_body_bytes,
        command_timeout_ms,
        body_chars,
      }
    }
  }
}

///|
fn parse_account(
  name : String,
  table : Map[String, @toml.TomlValue],
  defaults : Defaults,
) -> Account raise MailsError {
  let what = "account '" + name + "': "
  let host = match read_string(table, "host", what + "host") {
    Some(text) if !is_blank(text) => trim(text)
    _ => raise MailsError(what + "needs a 'host'")
  }
  let username = match read_string(table, "username", what + "username") {
    Some(text) if !is_blank(text) => trim(text)
    _ => raise MailsError(what + "needs a 'username'")
  }
  // An empty password is allowed at parse time so `mails encrypt --write`
  // can fill it in; connecting without one is rejected with a clear message.
  let password = match read_string(table, "password", what + "password") {
    Some(text) => trim(text)
    None => ""
  }
  let port = match read_int(table, "port", what + "port") {
    Some(number) => {
      if number <= 0 || number > 65535 {
        raise MailsError(what + "port must be between 1 and 65535")
      }
      number
    }
    None => 993
  }
  let use_tls = match read_bool(table, "tls", what + "tls") {
    Some(flag) => flag
    None => true
  }
  let tls_verify = match read_bool(table, "tls_verify", what + "tls_verify") {
    Some(flag) => flag
    None => true
  }
  let mailboxes = match
    read_string_array(table, "mailboxes", what + "mailboxes") {
    Some(items) => {
      if items.length() == 0 {
        raise MailsError(what + "needs at least one mailbox")
      }
      items
    }
    None => ["INBOX"]
  }
  let startup_sync = match
    read_bool(table, "startup_sync", what + "startup_sync") {
    Some(flag) => flag
    None => defaults.startup_sync
  }
  let idle_timeout_ms = match
    read_duration(table, "idle_timeout", what + "idle_timeout") {
    Some(ms) => {
      if ms < 1000 {
        raise MailsError(what + "idle_timeout must be at least 1s")
      }
      ms
    }
    None => defaults.idle_timeout_ms
  }
  let reconnect_ms = match
    read_duration(table, "reconnect_delay", what + "reconnect_delay") {
    Some(ms) => ms
    None => defaults.reconnect_ms
  }
  let max_body_bytes = match
    read_int(table, "max_body_bytes", what + "max_body_bytes") {
    Some(number) => {
      if number < 0 {
        raise MailsError(what + "max_body_bytes must not be negative")
      }
      number
    }
    None => defaults.max_body_bytes
  }
  let auth = match read_string(table, "auth", what + "auth") {
    Some(text) => {
      let kind = trim(text).to_lower()
      if kind == "xoauth2" || kind == "oauth2" || kind == "oauth" {
        OAuth2Login
      } else if kind == "password" || kind == "login" || kind == "" {
        PasswordLogin
      } else {
        raise MailsError(
          what + "auth must be \"password\" or \"xoauth2\", not '" + text + "'",
        )
      }
    }
    None => PasswordLogin
  }
  let oauth_provider = match
    read_string(table, "oauth_provider", what + "oauth_provider") {
    Some(text) => trim(text)
    None => ""
  }
  let oauth_client_id = match
    read_string(table, "oauth_client_id", what + "oauth_client_id") {
    Some(text) => trim(text)
    None => ""
  }
  let oauth_tenant = match
    read_string(table, "oauth_tenant", what + "oauth_tenant") {
    Some(text) => trim(text)
    None => ""
  }
  let oauth_scope = match
    read_string(table, "oauth_scope", what + "oauth_scope") {
    Some(text) => trim(text)
    None => ""
  }
  let oauth_device_endpoint = match
    read_string(table, "oauth_device_endpoint", what + "oauth_device_endpoint") {
    Some(text) => trim(text)
    None => ""
  }
  let oauth_token_endpoint = match
    read_string(table, "oauth_token_endpoint", what + "oauth_token_endpoint") {
    Some(text) => trim(text)
    None => ""
  }
  let oauth_token_file = match
    read_string(table, "oauth_token_file", what + "oauth_token_file") {
    Some(text) => trim(text)
    None => ""
  }
  if auth is OAuth2Login {
    let provider = if is_blank(oauth_provider) {
      "microsoft"
    } else {
      oauth_provider
    }
    if provider == "custom" {
      if is_blank(oauth_device_endpoint) ||
        is_blank(oauth_token_endpoint) ||
        is_blank(oauth_scope) {
        raise MailsError(
          what +
          "auth = \"xoauth2\" with oauth_provider = \"custom\" needs " +
          "oauth_device_endpoint, oauth_token_endpoint and oauth_scope",
        )
      }
    } else if provider != "microsoft" && provider != "google" {
      raise MailsError(
        what +
        "oauth_provider must be \"microsoft\", \"google\" or \"custom\", not '" +
        provider +
        "'",
      )
    }
    if is_blank(oauth_client_id) {
      raise MailsError(what + "auth = \"xoauth2\" needs oauth_client_id")
    }
  }
  let rules = parse_rules(table, defaults, what + "rules")
  {
    name,
    host,
    port,
    username,
    password,
    use_tls,
    tls_verify,
    auth,
    oauth_provider,
    oauth_client_id,
    oauth_tenant,
    oauth_scope,
    oauth_device_endpoint,
    oauth_token_endpoint,
    oauth_token_file,
    mailboxes,
    rules,
    startup_sync,
    idle_timeout_ms,
    reconnect_ms,
    max_body_bytes,
  }
}

///|
/// Parse a TOML document, resolving nothing (paths are handled by the caller).
pub fn parse_config(path : String, source : String) -> Config raise MailsError {
  let root = @toml.parse(source) catch {
    _ => raise MailsError("cannot parse " + path + " as TOML")
  }
  let table = match value_as_table(root) {
    Some(table) => table
    None => raise MailsError(path + " must contain a TOML table")
  }
  let general = table_field(table, "general", "[general]")
  let defaults = parse_defaults(general)
  let env_file_name = match general {
    Some(general_table) =>
      match read_string(general_table, "env_file", "general.env_file") {
        Some(text) if !is_blank(text) => trim(text)
        _ => ".env"
      }
    None => ".env"
  }
  let key_env = match general {
    Some(general_table) =>
      match read_string(general_table, "key_env", "general.key_env") {
        Some(text) if !is_blank(text) => trim(text)
        _ => default_key_env
      }
    None => default_key_env
  }
  let dir = match path.rev_find("/") {
    Some(index) => path[0:index].to_owned()
    None => "."
  }
  let env_file = if env_file_name.has_prefix("/") {
    env_file_name
  } else {
    dir + "/" + env_file_name
  }
  let rules = parse_rules(table, defaults, "global rules")
  let accounts : Array[Account] = []
  match table_field(table, "accounts", "[accounts]") {
    Some(accounts_table) =>
      for pair in accounts_table {
        let (name, value) = pair
        let account_table = match value_as_table(value) {
          Some(inner) => inner
          None => raise MailsError("account '" + name + "' must be a table")
        }
        accounts.push(parse_account(name, account_table, defaults))
      }
    None => ()
  }
  if accounts.length() == 0 {
    raise MailsError(path + " declares no [accounts.] tables")
  }
  { path, dir, env_file, key_env, defaults, rules, accounts, }
}

///|
/// Load and parse the configuration file.
pub async fn load_config(path : String) -> Config raise MailsError {
  let text = @fs.read_file(path) catch {
    _ => raise MailsError("cannot read the config file " + path)
  }
  let source = text.text() catch {
    _ => raise MailsError("the config file " + path + " is not valid UTF-8")
  }
  parse_config(path, source)
}

///|
/// Accounts selected by `--account`, or all of them.
pub fn selected_accounts(
  config : Config,
  name : String?,
) -> Array[Account] raise MailsError {
  match name {
    None => config.accounts
    Some(wanted) => {
      let out : Array[Account] = []
      for account in config.accounts {
        if account.name == wanted {
          out.push(account)
        }
      }
      if out.length() == 0 {
        raise MailsError("no account named '" + wanted + "' in " + config.path)
      }
      out
    }
  }
}

///|
/// Replace the `password` entry of one `[accounts.]` table in the TOML
/// source, leaving every other line untouched. Used by `mails encrypt --write`.
pub fn update_account_password(
  source : String,
  account : String,
  value : String,
) -> String raise MailsError {
  let lines : Array[String] = []
  for line in source.split("\n") {
    lines.push(line.to_owned())
  }
  let header = "[accounts." + account + "]"
  let mut in_table = false
  let mut table_seen = false
  let mut replaced = false
  let mut insert_at = -1
  let mut index = 0
  while index < lines.length() {
    let stripped = trim(lines[index])
    if stripped.has_prefix("[") {
      if in_table && !replaced && insert_at < 0 {
        insert_at = index
      }
      in_table = stripped == header
      if in_table {
        table_seen = true
        insert_at = index + 1
      }
    } else if in_table && stripped != "" {
      insert_at = index + 1
      if !replaced && stripped.has_prefix("password") {
        match stripped.find("=") {
          Some(_) => {
            let leading = lines[index][0:lines[index]
            .find("password")
            .unwrap_or(0)].to_owned()
            lines[index] = leading + "password = \"" + value + "\""
            replaced = true
          }
          None => ()
        }
      }
    }
    index = index + 1
  }
  if !table_seen {
    raise MailsError("no [accounts." + account + "] table in the config file")
  }
  if !replaced {
    if insert_at < 0 {
      insert_at = lines.length()
    }
    lines.insert(insert_at, "password = \"" + value + "\"")
  }
  let out = StringBuilder()
  let mut first = true
  for line in lines {
    if !first {
      out.write_char('\n')
    }
    first = false
    out.write_string(line)
  }
  out.to_string()
}

///|
/// A commented starter configuration written by `mails init`.
pub fn sample_config() -> String {
  let lines : Array[String] = [
    "# mails configuration. Keep this file next to the mails binary.", "", "[general]",
    "# Process mail that already exists when a mailbox is first watched.", "startup_sync = false",
    "# Leave IDLE and re-enter it this often (servers drop idle clients).", "idle_timeout = \"29m\"",
    "# Wait this long before reconnecting after a failure.", "reconnect_delay = \"30s\"",
    "# Only fetch this many bytes of each message body.", "max_body_bytes = 262144",
    "# File holding the encryption key; relative to this config file.", "env_file = \".env\"",
    "key_env = \"MAILS_KEY\"", "", "# Rules are matched against every new message. A rule runs its command",
    "# once per matching message. Placeholders such as {{subject}} and", "# {{from_q}} are expanded, and the same values are exported as MAILS_*",
    "# environment variables.", "", "[[rules]]", "name = \"urgent\"", "keywords = [\"urgent\", \"invoice\"]",
    "fields = [\"subject\", \"body\"]", "match = \"any\"          # \"any\" or \"all\"",
    "case_sensitive = false", "regex = false", "# command = 'notify-send \"Mail from {{from}}\" \"{{subject}}\"'",
    "command = 'printf \"%s\\n\" \"new mail: {{subject}}\" >> /tmp/mails.log'", "",
    "[accounts.example]", "host = \"imap.example.com\"", "port = 993", "username = \"you@example.com\"",
    "", "# Run: mails encrypt --account example --write", "password = \"\"", "",
    "mailboxes = [\"INBOX\"]", "# startup_sync = true        # override [general] for this account",
  ]
  let out = StringBuilder()
  for line in lines {
    out.write_string(line)
    out.write_char('\n')
  }
  out.to_string()
}