///|
/// Incremental JSON object builder for request bodies.
///
/// Field emission rules match Discord's PATCH semantics:
/// - `field`: always emitted
/// - `opt`: emitted only when `Some`
/// - `und`: `Missing` omitted, `Null` emitted as JSON `null`, `Value` emitted
///
/// ```mbt check
/// test "build a PATCH object with omitted and cleared fields" {
///   let nickname : String? = None
///   let avatar : @model.Undefinable[String] = Null
///   json_inspect(
///     @model.ObjBuilder()
///     .field("name", "discord.mbt")
///     .opt("nickname", nickname)
///     .und("avatar", avatar)
///     .build(),
///     content={ "name": "discord.mbt", "avatar": null },
///   )
/// }
/// ```
pub struct ObjBuilder {
  priv fields : Map[String, Json]
}

///|
/// Create an empty builder.
pub fn ObjBuilder::ObjBuilder() -> ObjBuilder {
  { fields: Map([]), }
}

///|
/// Set `key` to `value` unconditionally.
pub fn[T : ToJson] ObjBuilder::field(
  self : ObjBuilder,
  key : String,
  value : T,
) -> ObjBuilder {
  self.fields[key] = value.to_json()
  self
}

///|
/// Set `key` when `value` is present; omit the field otherwise.
pub fn[T : ToJson] ObjBuilder::opt(
  self : ObjBuilder,
  key : String,
  value : T?,
) -> ObjBuilder {
  if value is Some(v) {
    self.fields[key] = v.to_json()
  }
  self
}

///|
/// Set `key` from an `Undefinable`: a value, an explicit JSON `null`, or
/// omitted entirely when `Missing`.
pub fn[T : ToJson] ObjBuilder::und(
  self : ObjBuilder,
  key : String,
  value : Undefinable[T],
) -> ObjBuilder {
  match value {
    Missing => ()
    Null => self.fields[key] = Json::null()
    Value(v) => self.fields[key] = v.to_json()
  }
  self
}

///|
/// The accumulated JSON object.
pub fn ObjBuilder::build(self : ObjBuilder) -> Json {
  Json::object(self.fields)
}