///|
fn PdfDocument::pdf_util_dict_entry_single_object(
  self : PdfDocument,
  object : @syntax.PdfObject,
  transform : (@syntax.PdfObject) -> @syntax.PdfObject,
) -> @syntax.PdfObject {
  match object {
    PdfDictionary(entries) => {
      let rewritten = @syntax.pdf_recurse_dict(
        fn(child) { self.pdf_util_dict_entry_single_object(child, transform) },
        entries,
      )
      transform(rewritten)
    }
    PdfStreamObject(stream) =>
      match stream.dictionary {
        PdfDictionary(entries) => {
          stream.dictionary = @syntax.pdf_recurse_dict(
            fn(child) {
              self.pdf_util_dict_entry_single_object(child, transform)
            },
            entries,
          )
          transform(PdfStreamObject(stream))
        }
        _ => object
      }
    PdfArray(values) =>
      @syntax.pdf_recurse_array(
        fn(child) { self.pdf_util_dict_entry_single_object(child, transform) },
        values,
      )
    _ => object
  }
}

///|
fn PdfDocument::pdf_util_rewrite_dict_entries(
  self : PdfDocument,
  transform : (@syntax.PdfObject) -> @syntax.PdfObject,
) -> Unit {
  self.map_objects_in_place(fn(object) {
    self.pdf_util_dict_entry_single_object(object, transform)
  })
  self.trailer_dict = self.pdf_util_dict_entry_single_object(
    self.trailer_dict,
    transform,
  )
}

///|
fn PdfDocument::pdf_util_collect_dict_entry_values(
  self : PdfDocument,
  object : @syntax.PdfObject,
  key : @core.PdfName,
  output : Array[@syntax.PdfObject],
) -> Unit {
  match object {
    PdfDictionary(entries) => {
      for entry in entries {
        self.pdf_util_collect_dict_entry_values(entry.1, key, output)
      }
      match self.lookup_direct(key, object) {
        Some(value) => output.push(value)
        None => ()
      }
    }
    PdfStreamObject(stream) => {
      match stream.dictionary {
        PdfDictionary(entries) =>
          for entry in entries {
            self.pdf_util_collect_dict_entry_values(entry.1, key, output)
          }
        _ => ()
      }
      match self.lookup_direct(key, object) {
        Some(value) => output.push(value)
        None => ()
      }
    }
    PdfArray(values) =>
      for value in values {
        self.pdf_util_collect_dict_entry_values(value, key, output)
      }
    _ => ()
  }
}

///|
fn PdfDocument::pdf_util_remove_entry_transform(
  self : PdfDocument,
  key : @core.PdfName,
  search : @syntax.PdfObject?,
) -> (@syntax.PdfObject) -> @syntax.PdfObject {
  fn(object) {
    match search {
      None => try! object.remove_dict_entry(key)
      Some(expected) =>
        match self.lookup_direct(key, object) {
          Some(value) if value == expected => try! object.remove_dict_entry(key)
          _ => object
        }
    }
  }
}

///|
fn PdfDocument::pdf_util_replace_entry_transform(
  self : PdfDocument,
  key : @core.PdfName,
  value : @syntax.PdfObject,
  search : @syntax.PdfObject?,
) -> (@syntax.PdfObject) -> @syntax.PdfObject {
  fn(object) {
    match search {
      None => {
        let replaced : Result[@syntax.PdfObject, Error] = try
          object.replace_dict_entry(key, value)
        catch {
          err => Err(err)
        } noraise {
          value => Ok(value)
        }
        match replaced {
          Ok(updated) => updated
          Err(_) => object
        }
      }
      Some(expected) =>
        match self.lookup_direct(key, object) {
          Some(found) if found == expected =>
            try! object.replace_dict_entry(key, value)
          _ => object
        }
    }
  }
}

///|
/// Remove dictionary entries named `key` throughout parsed objects and trailer.
///
/// When `search` is supplied, an entry is removed only when its resolved value
/// equals `search`. Nested dictionaries, arrays, and stream dictionaries are
/// traversed recursively; deferred document objects are left untouched.
pub fn PdfDocument::remove_dict_entry_everywhere(
  self : PdfDocument,
  key : @core.PdfName,
  search? : @syntax.PdfObject? = None,
) -> Unit {
  self.pdf_util_rewrite_dict_entries(
    self.pdf_util_remove_entry_transform(key, search),
  )
}

///|
/// Replace dictionary entries named `key` throughout parsed objects and trailer.
///
/// Missing keys are ignored. When `search` is supplied, only entries whose
/// resolved current value equals `search` are replaced.
pub fn PdfDocument::replace_dict_entry_everywhere(
  self : PdfDocument,
  key : @core.PdfName,
  value : @syntax.PdfObject,
  search? : @syntax.PdfObject? = None,
) -> Unit {
  self.pdf_util_rewrite_dict_entries(
    self.pdf_util_replace_entry_transform(key, value, search),
  )
}

///|
/// Return resolved values for dictionary entries named `key`.
///
/// Traversal mirrors cpdf's dictionary-entry reporting helpers: direct nested
/// dictionaries, arrays, stream dictionaries, all parsed document objects, and
/// the trailer are scanned. The matching value itself is resolved through the
/// document object table.
pub fn PdfDocument::dict_entry_values(
  self : PdfDocument,
  key : @core.PdfName,
) -> Array[@syntax.PdfObject] {
  let object_numbers = self.object_numbers()
  let output : Array[@syntax.PdfObject] = Array(
    capacity=object_numbers.length(),
  )
  for number in object_numbers {
    self.pdf_util_collect_dict_entry_values(
      self.lookup_object_or_null(number),
      key,
      output,
    )
  }
  self.pdf_util_collect_dict_entry_values(self.trailer_dict, key, output)
  output
}

///|
/// Render dictionary entry values as PDF object syntax.
///
/// This is the side-effect-free native equivalent of cpdf's `print_dict_entry`.
pub fn PdfDocument::dict_entry_rendered_values(
  self : PdfDocument,
  key : @core.PdfName,
) -> Array[@core.PdfBytes] raise @core.PdfError {
  let values = self.dict_entry_values(key)
  let rendered : Array[@core.PdfBytes] = Array(capacity=values.length())
  for value in values {
    rendered.push(pdf_write_object(value))
  }
  rendered
}

///|
/// Return dictionary entry values in cpdf-style JSON format.
pub fn PdfDocument::dict_entries_json(
  self : PdfDocument,
  key : @core.PdfName,
  utf8? : Bool = false,
) -> @core.PdfBytes raise @core.PdfError {
  let entry_values = self.dict_entry_values(key)
  let values : Array[Json] = Array(capacity=entry_values.length())
  for value in entry_values {
    values.push(
      self.pdf_util_json_of_object(value, utf8, false, false, false, false),
    )
  }
  @utf8.encode(Json::array(values).stringify())
}

///|
fn pdf_util_source_key_name(key : String) -> @core.PdfName {
  @core.pdf_name_of_bytes(@ascii.encode(key))
}

///|
/// Render dictionary entry values for a cpdf-style slash-prefixed string key.
pub fn PdfDocument::dict_entry_rendered_values_string(
  self : PdfDocument,
  key : String,
) -> Array[@core.PdfBytes] raise @core.PdfError {
  self.dict_entry_rendered_values(pdf_util_source_key_name(key))
}

///|
/// Return dictionary entry values in cpdf-style JSON for a slash-prefixed key.
pub fn PdfDocument::dict_entries_json_string(
  self : PdfDocument,
  key : String,
  utf8? : Bool = false,
) -> @core.PdfBytes raise @core.PdfError {
  self.dict_entries_json(pdf_util_source_key_name(key), utf8~)
}

///|
/// Compatibility wrapper for `PdfDocument::dict_entry_values`.
pub fn pdf_dict_entry_values(
  document : PdfDocument,
  key : @core.PdfName,
) -> Array[@syntax.PdfObject] {
  document.dict_entry_values(key)
}

///|
/// Compatibility wrapper for `PdfDocument::dict_entry_rendered_values`.
pub fn pdf_print_dict_entry(
  document : PdfDocument,
  key : @core.PdfName,
) -> Array[@core.PdfBytes] raise @core.PdfError {
  document.dict_entry_rendered_values(key)
}

///|
/// Compatibility wrapper matching cpdftweak's string-key `print_dict_entry`.
pub fn pdf_print_dict_entry_string(
  document : PdfDocument,
  key : String,
) -> Array[@core.PdfBytes] raise @core.PdfError {
  document.dict_entry_rendered_values_string(key)
}

///|
/// Compatibility wrapper matching cpdftweak's `print_dict_entry` argument order.
pub fn pdf_print_dict_entry_cpdf_order(
  utf8 : Bool,
  document : PdfDocument,
  key : String,
) -> Array[@core.PdfBytes] raise @core.PdfError {
  ignore(utf8)
  document.dict_entry_rendered_values_string(key)
}

///|
/// Compatibility wrapper for `PdfDocument::dict_entries_json`.
pub fn pdf_get_dict_entries(
  document : PdfDocument,
  key : @core.PdfName,
  utf8? : Bool = false,
) -> @core.PdfBytes raise @core.PdfError {
  document.dict_entries_json(key, utf8~)
}

///|
/// Compatibility wrapper matching cpdftweak's string-key `get_dict_entries`.
pub fn pdf_get_dict_entries_string(
  document : PdfDocument,
  key : String,
  utf8? : Bool = false,
) -> @core.PdfBytes raise @core.PdfError {
  document.dict_entries_json_string(key, utf8~)
}

///|
/// Compatibility wrapper matching cpdftweak's `get_dict_entries` argument order.
pub fn pdf_get_dict_entries_cpdf_order(
  utf8 : Bool,
  document : PdfDocument,
  key : String,
) -> @core.PdfBytes raise @core.PdfError {
  document.dict_entries_json_string(key, utf8~)
}

///|
/// Compatibility wrapper for `PdfDocument::remove_dict_entry_everywhere`.
pub fn pdf_remove_dict_entry(
  document : PdfDocument,
  key : @core.PdfName,
  search? : @syntax.PdfObject? = None,
) -> Unit {
  document.remove_dict_entry_everywhere(key, search~)
}

///|
/// Compatibility wrapper matching cpdfutil's string-key `remove_dict_entry`.
pub fn PdfDocument::remove_dict_entry_string(
  self : PdfDocument,
  key : String,
  search? : @syntax.PdfObject? = None,
) -> Unit {
  self.remove_dict_entry_everywhere(
    @core.pdf_name_of_bytes(@ascii.encode(key)),
    search~,
  )
}

///|
/// Compatibility wrapper matching cpdfutil's string-key `remove_dict_entry`.
pub fn pdf_remove_dict_entry_string(
  document : PdfDocument,
  key : String,
  search? : @syntax.PdfObject? = None,
) -> Unit {
  document.remove_dict_entry_string(key, search~)
}

///|
/// Compatibility wrapper for `PdfDocument::replace_dict_entry_everywhere`.
pub fn pdf_replace_dict_entry(
  document : PdfDocument,
  key : @core.PdfName,
  value : @syntax.PdfObject,
  search? : @syntax.PdfObject? = None,
) -> Unit {
  document.replace_dict_entry_everywhere(key, value, search~)
}

///|
/// Compatibility wrapper matching cpdfutil's string-key `replace_dict_entry`.
pub fn PdfDocument::replace_dict_entry_string(
  self : PdfDocument,
  key : String,
  value : @syntax.PdfObject,
  search? : @syntax.PdfObject? = None,
) -> Unit {
  self.replace_dict_entry_everywhere(
    @core.pdf_name_of_bytes(@ascii.encode(key)),
    value,
    search~,
  )
}

///|
/// Compatibility wrapper matching cpdfutil's string-key `replace_dict_entry`.
pub fn pdf_replace_dict_entry_string(
  document : PdfDocument,
  key : String,
  value : @syntax.PdfObject,
  search? : @syntax.PdfObject? = None,
) -> Unit {
  document.replace_dict_entry_string(key, value, search~)
}