///|
/// What `bind_or` does with a configured key that the bound value does not
/// hold. `FromJson` ignores unknown keys, so a misspelled key has no effect
/// unless the check is on.
pub(all) enum UnknownKeys {
  /// Ignore unknown keys, as `FromJson` does. The default.
  Allow
  /// Fail for each configured key that the `ToJson` form of the bound value
  /// does not hold.
  Reject
  /// As `Reject`, but also accept the listed key paths, relative to the bound
  /// section, and the keys below them. Use it for a decoder that accepts a key
  /// that its encoder does not write, for example an alias.
  RejectExcept(Array[String])
} derive(Eq, Debug)

///|
pub extend UnknownKeys with Eq::{equal, not_equal}

///|
pub extend UnknownKeys with @debug.Debug::{to_repr}

///|
/// The detail of a failure at an unknown key.
let unknown_key_detail = "is an unknown key"

///|
/// Raise one `Validation` error with a failure for each key of `section`, the
/// configured section at `key`, that `written`, the `ToJson` form of the bound
/// value, does not hold. A key that a listed path names, or a key below it, is
/// accepted. A configured table above a listed path is checked against the
/// listed paths, also when the written form leaves the table out. The check
/// goes into tables and list items, and it stops where the
/// written form has no table or list of the same length, because the type
/// then reads that value in its own way.
fn Config::check_unknown_keys(
  self : Config,
  key : KeyPath,
  section : ConfigValue,
  written : Json,
  unknown : UnknownKeys,
) -> Unit raise ConfigError {
  let accepted = match unknown {
    Allow => return
    Reject => []
    RejectExcept(keys) => keys.map(text => KeyPath::parse(text).names)
  }
  let failures : Array[BindFailure] = []
  fn walk(
    steps : Array[DecodeStep],
    names : Array[String],
    configured : ConfigValue,
    written : Json,
  ) -> Unit {
    match (configured.ion.kind(), written) {
      (Struct(fields), Object(written_fields)) =>
        for field in fields {
          guard field.name().text() is Some(name) else { continue }
          let child_names = [..names, name]
          if accepted.iter().any(listed => starts_with(child_names, listed)) {
            continue
          }
          let child_steps : Array[DecodeStep] = [..steps, Field(name)]
          let ancestor = accepted
            .iter()
            .any(listed => starts_with(listed, child_names))
          match written_fields.get(name) {
            Some(child) =>
              walk(child_steps, child_names, { ion: field.value() }, child)
            // A table above a listed key path: the encoder may leave it out,
            // so check its keys against the listed paths only.
            None if ancestor && field.value().kind() is Struct(_) =>
              walk(
                child_steps,
                child_names,
                { ion: field.value() },
                Json::object(Map([])),
              )
            None => {
              let path = decode_path_below(Some(key), child_steps)
              failures.push({
                at: [{ path, blame: self.blame(path) }],
                detail: unknown_key_detail,
              })
            }
          }
        }
      (List(items) | Sexp(items), Array(written_items)) if items.length() ==
        written_items.length() =>
        for index, item in items {
          walk([..steps, Index(index)], names, { ion: item }, written_items[index])
        }
      _ => ()
    }
  }

  walk([], [], section, written)
  if !failures.is_empty() {
    raise Validation(failures~)
  }
}

///|
/// Whether `names` starts with every name of `prefix`, in order.
fn starts_with(names : Array[String], prefix : Array[String]) -> Bool {
  prefix.length() <= names.length() && names[:prefix.length()] == prefix[:]
}