src/gleeps/stdlib/dict.gleam

import gleeps/stdlib/option.{type Option}

/// A dictionary of keys and values.
///
/// Any type can be used for the keys and values of a dict, but all the keys
/// must be of the same type and all the values must be of the same type.
///
/// Each key can only be present in a dict once.
///
/// Dicts are not ordered in any way, and any unintentional ordering is not to
/// be relied upon in your code as it may change in future versions of Erlang
/// or Gleam.
///
/// See [the Erlang map module](https://erlang.org/doc/man/maps.html) for more
/// information.
///
pub type Dict(key, value)

/// "TransientDict" is a mutable view on a dictionary used internally by the
/// JavaScript target. No mutable API is exposed to the user.
///
/// Transients are to be treated as having a linear (single-use, think rust) type.
/// A transient value becomes invalid as soon as it's passed to one of the functions.
type TransientDict(key, value)

/// Convert a normal Dict to a transient dict.
/// A transient dict is a mutable copy of the original.
@external(erlang, "gleam_stdlib", "identity")
@external(javascript, "../dict.mjs", "toTransient")
fn to_transient(dict: Dict(key, value)) -> TransientDict(key, value)

/// Convert a transient dict back into a normal dict, freezing its contents.
/// Using the transient after this point is highly unsafe and leads to undefined behavior.
@external(erlang, "gleam_stdlib", "identity")
@external(javascript, "../dict.mjs", "fromTransient")
fn from_transient(transient: TransientDict(key, value)) -> Dict(key, value)

/// Determines the number of key-value pairs in the dict.
/// This function runs in constant time and does not need to iterate the dict.
///
/// ## Examples
///
/// ```gleam
/// assert new() |> size == 0
/// ```
///
/// ```gleam
/// assert new() |> insert("key", "value") |> size == 1
/// ```
///
@external(erlang, "maps", "size")
@external(javascript, "../dict.mjs", "size")
pub fn size(dict: Dict(k, v)) -> Int

/// Determines whether or not the dict is empty.
///
/// ## Examples
///
/// ```gleam
/// assert new() |> is_empty
/// ```
///
/// ```gleam
/// assert !{ new() |> insert("b", 1) |> is_empty }
/// ```
///
pub fn is_empty(dict: Dict(k, v)) -> Bool {
  size(dict) == 0
}

/// Converts the dict to a list of 2-element tuples `#(key, value)`, one for
/// each key-value pair in the dict.
///
/// The tuples in the list have no specific order.
///
/// ## Examples
///
/// Calling `to_list` on an empty `dict` returns an empty list.
///
/// ```gleam
/// assert new() |> to_list == []
/// ```
///
/// The ordering of elements in the resulting list is an implementation detail
/// that should not be relied upon.
///
/// ```gleam
/// assert new()
///   |> insert("b", 1)
///   |> insert("a", 0)
///   |> insert("c", 2)
///   |> to_list
///   == [#("a", 0), #("b", 1), #("c", 2)]
/// ```
///
@external(erlang, "maps", "to_list")
pub fn to_list(dict: Dict(k, v)) -> List(#(k, v)) {
  fold(dict, from: [], with: fn(acc, key, value) { [#(key, value), ..acc] })
}

/// Converts a list of 2-element tuples `#(key, value)` to a dict.
///
/// If two tuples have the same key the last one in the list will be the one
/// that is present in the dict.
///
@external(erlang, "maps", "from_list")
pub fn from_list(list: List(#(k, v))) -> Dict(k, v) {
  from_list_loop(to_transient(new()), list)
}

fn from_list_loop(
  transient: TransientDict(k, v),
  list: List(#(k, v)),
) -> Dict(k, v) {
  case list {
    [] -> from_transient(transient)
    [#(key, value), ..rest] ->
      from_list_loop(transient_insert(key, value, transient), rest)
  }
}

/// Determines whether or not a value is present in the dict for a given key.
///
/// ## Examples
///
/// ```gleam
/// assert new() |> insert("a", 0) |> has_key("a")
/// ```
///
/// ```gleam
/// assert !{ new() |> insert("a", 0) |> has_key("b") }
/// ```
///
@external(javascript, "../dict.mjs", "has")
pub fn has_key(dict: Dict(k, v), key: k) -> Bool {
  do_has_key(key, dict)
}

@external(erlang, "maps", "is_key")
fn do_has_key(key: k, dict: Dict(k, v)) -> Bool

/// Creates a fresh dict that contains no values.
///
@external(erlang, "maps", "new")
@external(javascript, "../dict.mjs", "make")
pub fn new() -> Dict(k, v)

/// Fetches a value from a dict for a given key.
///
/// The dict may not have a value for the key, so the value is wrapped in a
/// `Result`.
///
/// ## Examples
///
/// ```gleam
/// assert new() |> insert("a", 0) |> get("a") == Ok(0)
/// ```
///
/// ```gleam
/// assert new() |> insert("a", 0) |> get("b") == Error(Nil)
/// ```
///
@external(erlang, "gleam_stdlib", "map_get")
@external(javascript, "../dict.mjs", "get")
pub fn get(from: Dict(k, v), get: k) -> Result(v, Nil)

/// Inserts a value into the dict with the given key.
///
/// If the dict already has a value for the given key then the value is
/// replaced with the new value.
///
/// ## Examples
///
/// ```gleam
/// assert new() |> insert("a", 0) == from_list([#("a", 0)])
/// ```
///
/// ```gleam
/// assert new() |> insert("a", 0) |> insert("a", 5) == from_list([#("a", 5)])
/// ```
///
@external(javascript, "../dict.mjs", "insert")
pub fn insert(
  into dict: Dict(k, v),
  for key: k,
  insert value: v,
) -> Dict(k, v) {
  do_insert(key, value, dict)
}

@external(erlang, "maps", "put")
fn do_insert(key: k, value: v, dict: Dict(k, v)) -> Dict(k, v)

@external(erlang, "maps", "put")
@external(javascript, "../dict.mjs", "destructiveTransientInsert")
fn transient_insert(
  key: k,
  value: v,
  transient: TransientDict(k, v),
) -> TransientDict(k, v)

/// Updates all values in a given dict by calling a given function on each key
/// and value.
///
/// ## Examples
///
/// ```gleam
/// assert from_list([#(3, 3), #(2, 4)])
///   |> map_values(fn(key, value) { key * value })
///   == from_list([#(3, 9), #(2, 8)])
/// ```
///
@external(javascript, "../dict.mjs", "map")
pub fn map_values(in dict: Dict(k, v), with fun: fn(k, v) -> a) -> Dict(k, a) {
  do_map_values(fun, dict)
}

@external(erlang, "maps", "map")
fn do_map_values(f: fn(k, v) -> a, dict: Dict(k, v)) -> Dict(k, a)

/// Gets a list of all keys in a given dict.
///
/// Dicts are not ordered so the keys are not returned in any specific order. Do
/// not write code that relies on the order keys are returned by this function
/// as it may change in later versions of Gleam or Erlang.
///
/// ## Examples
///
/// ```gleam
/// assert from_list([#("a", 0), #("b", 1)]) |> keys == ["a", "b"]
/// ```
///
@external(erlang, "maps", "keys")
pub fn keys(dict: Dict(k, v)) -> List(k) {
  fold(dict, [], fn(acc, key, _value) { [key, ..acc] })
}

/// Gets a list of all values in a given dict.
///
/// Dicts are not ordered so the values are not returned in any specific order. Do
/// not write code that relies on the order values are returned by this function
/// as it may change in later versions of Gleam or Erlang.
///
/// ## Examples
///
/// ```gleam
/// assert from_list([#("a", 0), #("b", 1)]) |> values == [0, 1]
/// ```
///
@external(erlang, "maps", "values")
pub fn values(dict: Dict(k, v)) -> List(v) {
  fold(dict, [], fn(acc, _key, value) { [value, ..acc] })
}

/// Creates a new dict from a given dict, minus any entries that a given function
/// returns `False` for.
///
/// ## Examples
///
/// ```gleam
/// assert from_list([#("a", 0), #("b", 1)])
///   |> filter(fn(key, value) { value != 0 })
///   == from_list([#("b", 1)])
/// ```
///
/// ```gleam
/// assert from_list([#("a", 0), #("b", 1)])
///   |> filter(fn(key, value) { True })
///   == from_list([#("a", 0), #("b", 1)])
/// ```
///
pub fn filter(
  in dict: Dict(k, v),
  keeping predicate: fn(k, v) -> Bool,
) -> Dict(k, v) {
  do_filter(predicate, dict)
}

@external(erlang, "maps", "filter")
fn do_filter(f: fn(k, v) -> Bool, dict: Dict(k, v)) -> Dict(k, v) {
  to_transient(new())
  |> fold(over: dict, with: fn(transient, key, value) {
    case f(key, value) {
      True -> transient_insert(key, value, transient)
      False -> transient
    }
  })
  |> from_transient
}

/// Creates a new dict from a given dict, only including any entries for which the
/// keys are in a given list.
///
/// ## Examples
///
/// ```gleam
/// assert from_list([#("a", 0), #("b", 1)])
///   |> take(["b"])
///   == from_list([#("b", 1)])
/// ```
///
/// ```gleam
/// assert from_list([#("a", 0), #("b", 1)])
///   |> take(["a", "b", "c"])
///   == from_list([#("a", 0), #("b", 1)])
/// ```
///
pub fn take(
  from dict: Dict(k, v),
  keeping desired_keys: List(k),
) -> Dict(k, v) {
  do_take(desired_keys, dict)
}

@external(erlang, "maps", "with")
fn do_take(desired_keys: List(k), dict: Dict(k, v)) -> Dict(k, v) {
  do_take_loop(dict, desired_keys, to_transient(new()))
}

fn do_take_loop(
  dict: Dict(k, v),
  desired_keys: List(k),
  acc: TransientDict(k, v),
) -> Dict(k, v) {
  case desired_keys {
    [] -> from_transient(acc)
    [key, ..rest] ->
      case get(dict, key) {
        Ok(value) -> do_take_loop(dict, rest, transient_insert(key, value, acc))
        Error(_) -> do_take_loop(dict, rest, acc)
      }
  }
}

/// Creates a new dict from a pair of given dicts by combining their entries.
///
/// If there are entries with the same keys in both dicts the entry from the
/// second dict takes precedence.
///
/// ## Examples
///
/// ```gleam
/// let a = from_list([#("a", 0), #("b", 1)])
/// let b = from_list([#("b", 2), #("c", 3)])
/// assert merge(a, b) == from_list([#("a", 0), #("b", 2), #("c", 3)])
/// ```
///
@external(erlang, "maps", "merge")
pub fn merge(
  into dict: Dict(k, v),
  from new_entries: Dict(k, v),
) -> Dict(k, v) {
  combine(dict, new_entries, fn(_, new_entry) { new_entry })
}

/// Creates a new dict from a given dict with all the same entries except for the
/// one with a given key, if it exists.
///
/// ## Examples
///
/// ```gleam
/// assert from_list([#("a", 0), #("b", 1)]) |> delete("a")
///   == from_list([#("b", 1)])
/// ```
///
/// ```gleam
/// assert from_list([#("a", 0), #("b", 1)]) |> delete("c")
///   == from_list([#("a", 0), #("b", 1)])
/// ```
///
pub fn delete(from dict: Dict(k, v), delete key: k) -> Dict(k, v) {
  to_transient(dict) |> transient_delete(key, _) |> from_transient
}

@external(erlang, "maps", "remove")
@external(javascript, "../dict.mjs", "destructiveTransientDelete")
fn transient_delete(a: k, b: TransientDict(k, v)) -> TransientDict(k, v)

/// Creates a new dict from a given dict with all the same entries except any with
/// keys found in a given list.
///
/// ## Examples
///
/// ```gleam
/// assert from_list([#("a", 0), #("b", 1)]) |> drop(["a"])
///   == from_list([#("b", 1)])
/// ```
///
/// ```gleam
/// assert from_list([#("a", 0), #("b", 1)]) |> drop(["c"])
///   == from_list([#("a", 0), #("b", 1)])
/// ```
///
/// ```gleam
/// assert from_list([#("a", 0), #("b", 1)]) |> drop(["a", "b", "c"])
///   == from_list([])
/// ```
///
pub fn drop(
  from dict: Dict(k, v),
  drop disallowed_keys: List(k),
) -> Dict(k, v) {
  do_drop(disallowed_keys, dict)
}

@external(erlang, "maps", "without")
fn do_drop(disallowed_keys: List(k), dict: Dict(k, v)) -> Dict(k, v) {
  drop_loop(to_transient(dict), disallowed_keys)
}

fn drop_loop(
  transient: TransientDict(k, v),
  disallowed_keys: List(k),
) -> Dict(k, v) {
  case disallowed_keys {
    [] -> from_transient(transient)
    [key, ..rest] -> drop_loop(transient_delete(key, transient), rest)
  }
}

/// Creates a new dict with one entry inserted or updated using a given function.
///
/// If there was not an entry in the dict for the given key then the function
/// gets `None` as its argument, otherwise it gets `Some(value)`.
///
/// ## Examples
///
/// ```gleam
/// let dict = from_list([#("a", 0)])
/// let increment = fn(x) {
///   case x {
///     Some(i) -> i + 1
///     None -> 0
///   }
/// }
///
/// assert upsert(dict, "a", increment) == from_list([#("a", 1)])
/// ```
///
/// ```gleam
/// assert upsert(dict, "b", increment) == from_list([#("a", 0), #("b", 0)])
/// ```
///
pub fn upsert(
  in dict: Dict(k, v),
  update key: k,
  with fun: fn(Option(v)) -> v,
) -> Dict(k, v) {
  case get(dict, key) {
    Ok(value) -> insert(dict, key, fun(option.Some(value)))
    Error(_) -> insert(dict, key, fun(option.None))
  }
}

/// Combines all entries into a single value by calling a given function on each
/// one.
///
/// Dicts are not ordered so the values are not returned in any specific order. Do
/// not write code that relies on the order entries are used by this function
/// as it may change in later versions of Gleam or Erlang.
///
/// ## Examples
///
/// ```gleam
/// let dict = from_list([#("a", 1), #("b", 3), #("c", 9)])
/// assert fold(dict, 0, fn(accumulator, key, value) { accumulator + value })
///   == 13
/// ```
///
/// ```gleam
/// import gleam/string
///
/// let dict = from_list([#("a", 1), #("b", 3), #("c", 9)])
/// assert
///   fold(dict, "", fn(accumulator, key, value) {
///     string.append(accumulator, key)
///   })
///   == "abc"
/// ```
///
@external(javascript, "../dict.mjs", "fold")
pub fn fold(
  over dict: Dict(k, v),
  from initial: acc,
  with fun: fn(acc, k, v) -> acc,
) -> acc {
  let fun = fn(key, value, acc) { fun(acc, key, value) }
  do_fold(fun, initial, dict)
}

@external(erlang, "maps", "fold")
fn do_fold(fun: fn(k, v, acc) -> acc, initial: acc, dict: Dict(k, v)) -> acc

/// Calls a function for each key and value in a dict, discarding the return
/// value.
///
/// Useful for producing a side effect for every item of a dict.
///
/// ```gleam
/// import gleam/io
///
/// let dict = from_list([#("a", "apple"), #("b", "banana"), #("c", "cherry")])
///
/// assert
///   each(dict, fn(k, v) {
///     io.println(k <> " => " <> v)
///   })
///   == Nil
/// // a => apple
/// // b => banana
/// // c => cherry
/// ```
///
/// The order of elements in the iteration is an implementation detail that
/// should not be relied upon.
///
pub fn each(dict: Dict(k, v), fun: fn(k, v) -> a) -> Nil {
  fold(dict, Nil, fn(nil, k, v) {
    fun(k, v)
    nil
  })
}

/// Creates a new dict from a pair of given dicts by combining their entries.
///
/// If there are entries with the same keys in both dicts the given function is
/// used to determine the new value to use in the resulting dict.
///
/// ## Examples
///
/// ```gleam
/// let a = from_list([#("a", 0), #("b", 1)])
/// let b = from_list([#("a", 2), #("c", 3)])
/// assert combine(a, b, fn(one, other) { one + other })
///   == from_list([#("a", 2), #("b", 1), #("c", 3)])
/// ```
///
pub fn combine(
  dict: Dict(k, v),
  other: Dict(k, v),
  with fun: fn(v, v) -> v,
) -> Dict(k, v) {
  do_combine(fn(_, l, r) { fun(l, r) }, dict, other)
}

@external(erlang, "maps", "merge_with")
fn do_combine(
  combine: fn(k, v, v) -> v,
  left: Dict(k, v),
  right: Dict(k, v),
) -> Dict(k, v) {
  let #(big, small, combine) = case size(left) >= size(right) {
    True -> #(left, right, combine)
    False -> #(right, left, fn(k, l, r) { combine(k, r, l) })
  }

  to_transient(big)
  |> fold(over: small, with: fn(transient, key, value) {
    let update = fn(existing) { combine(key, existing, value) }
    transient_update_with(key, update, value, transient)
  })
  |> from_transient
}

@external(erlang, "maps", "update_with")
@external(javascript, "../dict.mjs", "destructiveTransientUpdateWith")
fn transient_update_with(
  key: k,
  fun: fn(v) -> v,
  init: v,
  transient: TransientDict(k, v),
) -> TransientDict(k, v)

@internal
pub fn group(key: fn(v) -> k, list: List(v)) -> Dict(k, List(v)) {
  group_loop(to_transient(new()), key, list)
}

fn group_loop(
  transient: TransientDict(k, List(v)),
  to_key: fn(v) -> k,
  list: List(v),
) -> Dict(k, List(v)) {
  case list {
    [] -> from_transient(transient)
    [value, ..rest] -> {
      let key = to_key(value)
      let update = fn(existing) { [value, ..existing] }

      transient
      |> transient_update_with(key, update, [value], _)
      |> group_loop(to_key, rest)
    }
  }
}