///|
/// An entry of a description list: one or more terms and an optional description.
pub(all) struct DlistItem {
  terms : Array[Node]
  mut desc : Node?
}

///|
/// A node of the document tree. Ruby's class hierarchy (AbstractNode,
/// AbstractBlock, Document, Section, Block, List, ListItem, Table,
/// Table::Column, Table::Cell, Inline) is flattened into this one struct; the
/// `context` tells which fields are meaningful.
pub(all) struct Node {
  mut context : Context
  mut node_name : String
  priv mut parent_ : Node?
  priv mut document_ : Node? // None for the document itself
  attributes : Attributes
  mut id : String?
  priv passthroughs : Array[Passthrough]
  priv mut passthroughs_locked : Bool
  // AbstractBlock
  blocks : Array[Node]
  mut content_model : ContentModel
  mut level : Int
  mut numeral : String?
  mut source_location : Cursor?
  mut style : String?
  mut subs : Array[Sub]
  priv mut default_subs : Array[Sub]?
  priv mut title_ : String?
  priv mut converted_title : String?
  priv mut caption_ : String?
  priv mut next_section_index : Int
  priv mut next_section_ordinal : Int
  // Block
  mut lines : Array[String]
  // Section
  mut index : Int
  mut sectname : String?
  mut special : Bool
  mut numbered : Numbered
  // ListItem / Inline / Cell
  priv mut text_ : String?
  mut marker : String?
  // Ruby stores an implicit ordered list style as a Symbol, which does not
  // match ORDERED_LIST_KEYWORDS; track that quirk.
  priv mut symbolic_style : Bool
  // Description list items (context == Dlist)
  dlist_items : Array[DlistItem]
  // Inline
  mut inline_type : String?
  mut target : String?
  // Table, column and cell data
  priv mut table_ : TableData?
  priv mut cell_ : CellData?
  // Document data (context == Document)
  priv mut doc_ : DocData?
}

///|
fn Node::alloc(
  parent : Node?,
  context : Context,
  attributes? : Attributes,
  document? : Node,
) -> Node {
  let doc = match document {
    Some(d) => Some(d)
    None =>
      match parent {
        Some(p) => Some(p.document())
        None => None
      }
  }
  let level = match parent {
    Some(p) => p.level
    None => 0
  }
  {
    context,
    node_name: context.name(),
    parent_: parent,
    document_: doc,
    attributes: match attributes {
      Some(a) => a.copy()
      None => Attributes::new()
    },
    id: None,
    passthroughs: [],
    passthroughs_locked: false,
    blocks: [],
    content_model: Compound,
    level,
    numeral: None,
    source_location: None,
    style: None,
    subs: [],
    default_subs: None,
    title_: None,
    converted_title: None,
    caption_: None,
    next_section_index: 0,
    next_section_ordinal: 1,
    lines: [],
    index: 0,
    sectname: None,
    special: false,
    numbered: NotNumbered,
    text_: None,
    marker: None,
    symbolic_style: false,
    dlist_items: [],
    inline_type: None,
    target: None,
    table_: None,
    cell_: None,
    doc_: None,
  }
}

// ---------------------------------------------------------------------------
// AbstractNode

///|
/// The parent node (None for a document).
pub fn Node::parent(self : Node) -> Node? {
  self.parent_
}

///|
/// Sets the parent (and the document) of this node.
pub fn Node::set_parent(self : Node, parent : Node) -> Unit {
  self.parent_ = Some(parent)
  self.document_ = Some(parent.document())
}

///|
/// The document this node belongs to.
pub fn Node::document(self : Node) -> Node {
  match self.document_ {
    Some(d) => d
    None => self
  }
}

///|
fn Node::doc(self : Node) -> DocData {
  match self.document().doc_ {
    Some(d) => d
    None => abort("node is not attached to a document")
  }
}

///|
pub fn Node::is_block(self : Node) -> Bool {
  !self.is_inline() && self.context != TableColumn
}

///|
pub fn Node::is_inline(self : Node) -> Bool {
  self.node_name.has_prefix("inline_")
}

///|
/// Ruby `attr(name, default, fallback)`: the attribute value as a string if
/// truthy; otherwise, if `fallback` is set and the node has a parent, the
/// document attribute; otherwise `default`.
pub fn Node::attr(
  self : Node,
  name : String,
  default? : String,
  inherited? : Bool = false,
  fallback_name? : String,
) -> String? {
  match self.attributes.str(name) {
    Some(v) => Some(v)
    None =>
      if (inherited || fallback_name is Some(_)) && self.parent_ is Some(_) {
        match self.document().attributes.str(fallback_name.unwrap_or(name)) {
          Some(v) => Some(v)
          None => default
        }
      } else {
        default
      }
  }
}

///|
/// Ruby `attr?(name, expected, fallback)`.
pub fn Node::has_attr(
  self : Node,
  name : String,
  expected? : String,
  inherited? : Bool = false,
) -> Bool {
  match expected {
    Some(e) => {
      let v = match self.attributes.str(name) {
        Some(v) => Some(v)
        None =>
          if inherited && self.parent_ is Some(_) {
            self.document().attributes.str(name)
          } else {
            None
          }
      }
      v == Some(e)
    }
    None =>
      self.attributes.contains(name) ||
      (
        inherited &&
        self.parent_ is Some(_) &&
        self.document().attributes.contains(name)
      )
  }
}

///|
/// Ruby `set_attr(name, value, overwrite)`.
pub fn Node::set_attr(
  self : Node,
  name : String,
  value? : AttrVal = Str(""),
  overwrite? : Bool = true,
) -> Bool {
  if !overwrite && self.attributes.contains(name) {
    false
  } else {
    self.attributes.set(name, value)
    true
  }
}

///|
pub fn Node::remove_attr(self : Node, name : String) -> AttrVal? {
  self.attributes.remove(name)
}

///|
/// Ruby `option?(name)`.
pub fn Node::has_option(self : Node, name : String) -> Bool {
  self.attributes.truthy("\{name}-option")
}

///|
/// Ruby `set_option(name)`: returns false if the option was already set.
pub fn Node::set_option(self : Node, name : String) -> Bool {
  if self.attributes.truthy("\{name}-option") {
    false
  } else {
    self.attributes.set_str("\{name}-option", "")
    true
  }
}

///|
/// Ruby `enabled_options`.
pub fn Node::enabled_options(self : Node) -> Array[String] {
  let out = []
  for k, _ in self.attributes.iter() {
    if k is Name(n) && n.has_suffix("-option") {
      out.push(n.unsafe_substring(start=0, end=n.length() - 7))
    }
  }
  out
}

///|
pub fn Node::update_attributes(
  self : Node,
  new_attributes : Attributes,
) -> Unit {
  self.attributes.update(new_attributes)
}

///|
pub fn Node::role(self : Node) -> String? {
  self.attributes.str("role")
}

///|
pub fn Node::roles(self : Node) -> Array[String] {
  match self.attributes.str("role") {
    Some(v) => @rb.split_ws(v)
    None => []
  }
}

///|
pub fn Node::has_role(self : Node, name : String) -> Bool {
  match self.attributes.str("role") {
    Some(v) => " \{v} ".contains(" \{name} ")
    None => false
  }
}

///|
/// Ruby `role?(expected)`.
pub fn Node::role_is(self : Node, expected? : String) -> Bool {
  match expected {
    Some(e) => self.attributes.str("role") == Some(e)
    None => self.attributes.contains("role")
  }
}

///|
/// Sets (or clears) the id of this node (Ruby `node.id = value`).
pub fn Node::set_id(self : Node, id : String?) -> Unit {
  self.id = id
}

///|
pub fn Node::set_role(self : Node, names : String) -> Unit {
  self.attributes.set_str("role", names)
}

///|
pub fn Node::add_role(self : Node, name : String) -> Bool {
  match self.attributes.str("role") {
    Some(v) =>
      if " \{v} ".contains(" \{name} ") {
        false
      } else {
        self.attributes.set_str("role", "\{v} \{name}")
        true
      }
    None => {
      self.attributes.set_str("role", name)
      true
    }
  }
}

///|
pub fn Node::remove_role(self : Node, name : String) -> Bool {
  match self.attributes.str("role") {
    Some(v) => {
      let parts = @rb.split_ws(v)
      match parts.search(name) {
        Some(i) => {
          parts.remove(i) |> ignore
          if parts.is_empty() {
            self.attributes.remove("role") |> ignore
          } else {
            self.attributes.set_str("role", parts.join(" "))
          }
          true
        }
        None => false
      }
    }
    None => false
  }
}

///|
/// Ruby `reftext`: the reftext attribute with reftext substitutions applied.
pub fn Node::reftext(self : Node) -> String? {
  match self.context {
    Anchor if self.is_inline() =>
      match self.text_ {
        Some(t) => Some(self.apply_reftext_subs(t))
        None => None
      }
    _ =>
      match self.attributes.str("reftext") {
        Some(v) => Some(self.apply_reftext_subs(v))
        None => None
      }
  }
}

///|
pub fn Node::has_reftext(self : Node) -> Bool {
  if self.is_inline() {
    self.text_ is Some(_) &&
    (self.inline_type == Some("ref") || self.inline_type == Some("bibref"))
  } else {
    self.attributes.contains("reftext")
  }
}

// ---------------------------------------------------------------------------
// AbstractBlock

///|
pub fn Node::file(self : Node) -> String? {
  match self.source_location {
    Some(c) => c.file
    None => None
  }
}

///|
pub fn Node::lineno(self : Node) -> Int? {
  match self.source_location {
    Some(c) => Some(c.lineno)
    None => None
  }
}

///|
/// Sets the context (and node name) of this node.
pub fn Node::set_context(self : Node, context : Context) -> Unit {
  self.context = context
  self.node_name = context.name()
}

///|
/// Appends a child block (Ruby `<<`), assigning section numbers as needed.
pub fn Node::append(self : Node, block : Node) -> Unit {
  if (self.context == Section || self.context == Document) &&
    block.context == Section {
    self.assign_numeral(block)
  }
  match block.parent_ {
    Some(p) if physical_equal(p, self) => ()
    _ => block.set_parent(self)
  }
  self.blocks.push(block)
}

///|
pub fn Node::has_blocks(self : Node) -> Bool {
  !self.blocks.is_empty()
}

///|
/// Whether this node has child sections.
pub fn Node::has_sections(self : Node) -> Bool {
  match self.context {
    Section | Document => self.next_section_index > 0
    _ => false
  }
}

///|
/// Ruby `number`: the numeral as an integer when possible.
pub fn Node::number(self : Node) -> AttrVal {
  match self.numeral {
    Some(n) =>
      match @rb.parse_int(n) {
        Some(i) => Int(i)
        None => Str(n)
      }
    None => Nil
  }
}

///|
/// The child sections.
pub fn Node::sections(self : Node) -> Array[Node] {
  self.blocks.filter(b => b.context == Section)
}

///|
/// The raw (unsubstituted) title.
pub fn Node::raw_title(self : Node) -> String? {
  self.title_
}

///|
/// The converted title (title substitutions applied), memoized.
pub fn Node::title(self : Node) -> String? {
  if self.context == Document {
    return self.doctitle()
  }
  match self.converted_title {
    Some(t) => Some(t)
    None =>
      match self.title_ {
        Some(t) => {
          let c = self.apply_title_subs(t)
          self.converted_title = Some(c)
          Some(c)
        }
        None => None
      }
  }
}

///|
pub fn Node::has_title(self : Node) -> Bool {
  if self.context == Document {
    return self.doc().header is Some(_) || self.attributes.contains("title")
  }
  self.title_ is Some(_)
}

///|
pub fn Node::set_title(self : Node, val : String?) -> Unit {
  if self.context == Document {
    let sect = match self.doc().header {
      Some(h) => h
      None => {
        let h = Node::new_section(Some(self), level=0)
        h.sectname = Some("header")
        self.doc().header = Some(h)
        h
      }
    }
    sect.set_title(val)
    return
  }
  self.converted_title = None
  self.title_ = val
}

///|
/// The caption (for admonitions, the textlabel attribute).
pub fn Node::caption(self : Node) -> String? {
  if self.context == Admonition {
    self.attributes.str("textlabel")
  } else {
    self.caption_
  }
}

///|
pub fn Node::set_caption(self : Node, caption : String?) -> Unit {
  self.caption_ = caption
}

///|
/// The caption followed by the title.
pub fn Node::captioned_title(self : Node) -> String {
  "\{self.caption_.unwrap_or("")}\{self.title().unwrap_or("")}"
}

///|
pub fn Node::list_marker_keyword(self : Node, list_type? : String) -> String? {
  let lt = match list_type {
    Some(x) => Some(x)
    None => if self.symbolic_style { None } else { self.style }
  }
  match lt {
    Some("loweralpha") => Some("a")
    Some("lowerroman") => Some("i")
    Some("upperalpha") => Some("A")
    Some("upperroman") => Some("I")
    _ => None
  }
}

///|
pub fn Node::has_sub(self : Node, name : Sub) -> Bool {
  self.subs.contains(name)
}

///|
pub fn Node::remove_sub(self : Node, sub : Sub) -> Unit {
  match self.subs.search(sub) {
    Some(i) => self.subs.remove(i) |> ignore
    None => ()
  }
}

///|
/// The alt text of an image block, with special characters escaped.
pub fn Node::alt(self : Node) -> String {
  if self.is_inline() {
    return self.attributes.str("alt").unwrap_or("")
  }
  match self.attributes.str("alt") {
    Some(text) =>
      if Some(text) == self.attributes.str("default-alt") {
        sub_specialchars(text)
      } else {
        let text = sub_specialchars(text)
        if replaceable_text_rx.matches(text) {
          self.sub_replacements(text)
        } else {
          text
        }
      }
    None => ""
  }
}

///|
fn caption_attribute_name(ctx : String) -> String? {
  match ctx {
    "example" => Some("example-caption")
    "figure" => Some("figure-caption")
    "listing" => Some("listing-caption")
    "table" => Some("table-caption")
    _ => None
  }
}

///|
/// Ruby `xreftext(xrefstyle)`.
pub fn Node::xreftext(self : Node, xrefstyle? : String) -> String? {
  match self.context {
    Section => return self.section_xreftext(xrefstyle?)
    Document =>
      return match self.reftext() {
        Some(v) if v != "" => Some(v)
        _ => self.title()
      }
    _ => ()
  }
  if self.is_inline() {
    return self.reftext()
  }
  match self.reftext() {
    Some(v) if v != "" => return Some(v)
    _ => ()
  }
  match (xrefstyle, self.title_, self.caption_) {
    (Some(style), Some(_), Some(caption)) if caption != "" => {
      let doc = self.document()
      let prefix = match
        (self.numeral, caption_attribute_name(self.context.name())) {
        (Some(_), Some(attr_name)) => doc.attributes.str(attr_name)
        _ => None
      }
      match style {
        "full" => {
          let quoted_title = sub_placeholder(
            self.sub_quotes(
              if doc.doc().compat_mode {
                "``%s''"
              } else {
                "\"`%s`\""
              },
            ),
            self.title().unwrap_or(""),
          )
          match prefix {
            Some(p) =>
              Some("\{p} \{self.numeral.unwrap_or("")}, \{quoted_title}")
            None => Some("\{@rb.chomp_suffix(caption, ". ")}, \{quoted_title}")
          }
        }
        "short" =>
          match prefix {
            Some(p) => Some("\{p} \{self.numeral.unwrap_or("")}")
            None => Some(@rb.chomp_suffix(caption, ". "))
          }
        _ => self.title()
      }
    }
    _ => self.title()
  }
}

///|
/// Ruby `assign_caption(value, caption_context)`.
pub fn Node::assign_caption(
  self : Node,
  value : String?,
  caption_context? : String,
) -> Unit {
  if self.caption_ is Some(_) || self.title_ is None {
    return
  }
  let doc = self.document()
  let v0 = match value {
    Some(x) => Some(x)
    None => doc.attributes.str("caption")
  }
  match v0 {
    Some(v) => self.caption_ = Some(v)
    None => {
      let ctx = caption_context.unwrap_or(self.context.name())
      match caption_attribute_name(ctx) {
        Some(attr_name) =>
          match doc.attributes.str(attr_name) {
            Some(prefix) => {
              let num = doc.increment_and_store_counter("\{ctx}-number", self)
              self.numeral = Some(num)
              self.caption_ = Some("\{prefix} \{num}. ")
            }
            None => ()
          }
        None => ()
      }
    }
  }
}

///|
/// Assigns the next index and numeral to the child `section`.
pub fn Node::assign_numeral(self : Node, section : Node) -> Unit {
  section.index = self.next_section_index
  self.next_section_index = section.index + 1
  if section.numbered != NotNumbered {
    let doc = self.document()
    let sectname = section.sectname.unwrap_or("")
    if sectname == "appendix" {
      let n = doc.counter("appendix-number", seed="A")
      section.numeral = Some(n)
      section.caption_ = match doc.attributes.str("appendix-caption") {
        Some(caption) => Some("\{caption} \{n}: ")
        None => Some("\{n}. ")
      }
    } else if sectname == "chapter" || section.numbered == NumberedChapter {
      section.numeral = Some(doc.counter("chapter-number", seed="1"))
    } else {
      section.numeral = Some(
        if sectname == "part" {
          @rb.int_to_roman(self.next_section_ordinal)
        } else {
          self.next_section_ordinal.to_string()
        },
      )
      self.next_section_ordinal += 1
    }
  }
}

///|
/// Recomputes section indexes and numerals.
pub fn Node::reindex_sections(self : Node) -> Unit {
  self.next_section_index = 0
  self.next_section_ordinal = 1
  for block in self.blocks {
    if block.context == Section {
      self.assign_numeral(block)
      block.reindex_sections()
    }
  }
}

///|
/// Content of this block: converted children (compound), substituted text
/// (simple), or substituted lines (verbatim/raw).
pub fn Node::content(self : Node) -> String {
  // Ruby `Inline#content` is an alias of `Inline#text`
  if self.is_inline() {
    return self.text_.unwrap_or("")
  }
  match self.context {
    Document => {
      self.attributes.remove("title") |> ignore
      return self.blocks.map(b => b.convert()).join("\n")
    }
    TableCell => return self.cell_content().join("\n")
    _ => ()
  }
  if self.context == ListItem || self.is_list() {
    return self.blocks.map(b => b.convert()).join("\n")
  }
  match self.content_model {
    Compound => self.blocks.map(b => b.convert()).join("\n")
    Simple => self.apply_subs(self.lines.join("\n"), self.subs)
    Verbatim | Raw => {
      let result = self.apply_subs_lines(self.lines, self.subs)
      if result.length() < 2 {
        result.get(0).unwrap_or("")
      } else {
        let mut start = 0
        let mut end = result.length()
        while start < end && @rb.rstrip(result[start]) == "" {
          start += 1
        }
        while end > start && @rb.rstrip(result[end - 1]) == "" {
          end -= 1
        }
        result[start:end].join("\n")
      }
    }
    _ => {
      if self.content_model != Empty {
        log_warn(
          "unknown content model '\{self.content_model.name()}' for block: \{self.context.name()}",
        )
      }
      ""
    }
  }
}

///|
/// The source (lines joined by LF).
pub fn Node::source(self : Node) -> String {
  match self.cell_ {
    Some(c) => c.text
    None => self.lines.join("\n")
  }
}

///|
/// Converts this node using the document's converter.
pub fn Node::convert(self : Node) -> String {
  if self.context == Document {
    return self.convert_document()
  }
  if !self.is_inline() {
    self.document().playback_attributes(self.attributes)
  }
  let result = self.document().converter().convert(self, self.node_name)
  if self.context == Colist {
    self.doc().catalog.callouts.next_list()
  }
  result
}

///|
/// Whether this is a list (ulist, olist, dlist or colist).
pub fn Node::is_list(self : Node) -> Bool {
  self.context is (Ulist | Olist | Dlist | Colist)
}

///|
/// Whether this list is an outline (ulist or olist).
pub fn Node::is_outline(self : Node) -> Bool {
  self.context is (Ulist | Olist)
}

///|
/// The next block after this one, walking up the tree (Ruby `next_adjacent_block`).
pub fn Node::next_adjacent_block(self : Node) -> Node? {
  if self.context == Document {
    return None
  }
  guard self.parent_ is Some(p) else { return None }
  if p.context == Dlist && self.context == ListItem {
    let items = p.dlist_items
    let mut idx = -1
    for i, item in items {
      if item.terms.iter().any(t => physical_equal(t, self)) ||
        (item.desc is Some(d) && physical_equal(d, self)) {
        idx = i
        break
      }
    }
    if idx >= 0 && idx + 1 < items.length() {
      let sib = items[idx + 1]
      // Ruby returns the [terms, desc] pair; callers use its first term
      return Some(sib.terms[0])
    }
    return p.next_adjacent_block()
  }
  let mut idx = -1
  for i, b in p.blocks {
    if physical_equal(b, self) {
      idx = i
      break
    }
  }
  if idx >= 0 && idx + 1 < p.blocks.length() {
    Some(p.blocks[idx + 1])
  } else {
    p.next_adjacent_block()
  }
}

///|
/// Selector for `find_by`.
pub(all) enum FindVerdict {
  Accept
  Reject
  Prune
  Stop
  Skip // falsy: not accepted, keep walking
}

///|
priv suberror StopIteration

///|
/// Ruby `find_by(selector) {|node| ... }`: walks the tree and collects matching nodes.
pub fn Node::find_by(
  self : Node,
  context? : Context,
  style? : String,
  role? : String,
  id? : String,
  traverse_documents? : Bool = false,
  filter? : (Node) -> FindVerdict,
) -> Array[Node] {
  let result = []
  self.find_by_internal(
    context~,
    style~,
    role~,
    id~,
    traverse_documents~,
    filter~,
    result,
  ) catch {
    StopIteration => ()
  }
  result
}

///|
fn Node::find_by_internal(
  self : Node,
  context~ : Context?,
  style~ : String?,
  role~ : String?,
  id~ : String?,
  traverse_documents~ : Bool,
  filter~ : ((Node) -> FindVerdict)?,
  result : Array[Node],
) -> Unit raise StopIteration {
  let any_context = context is None
  let matches = (any_context || context == Some(self.context)) &&
    (style is None || style == self.style) &&
    (role is None || self.has_role(role.unwrap())) &&
    (id is None || id == self.id)
  if matches {
    match filter {
      Some(f) =>
        match f(self) {
          Prune => {
            result.push(self)
            if id is Some(_) {
              raise StopIteration
            }
            return
          }
          Reject => {
            if id is Some(_) {
              raise StopIteration
            }
            return
          }
          Stop => raise StopIteration
          Accept => {
            result.push(self)
            if id is Some(_) {
              raise StopIteration
            }
          }
          Skip => if id is Some(_) { raise StopIteration }
        }
      None => {
        result.push(self)
        if id is Some(_) {
          raise StopIteration
        }
      }
    }
  }
  let recurse = fn(b : Node, ctx : Context?) raise StopIteration {
    b.find_by_internal(
      context=ctx,
      style~,
      role~,
      id~,
      traverse_documents~,
      filter~,
      result,
    )
  }
  match self.context {
    Document =>
      if context != Some(Document) {
        match self.doc().header {
          Some(h) if any_context || context == Some(Section) =>
            recurse(h, context)
          _ => ()
        }
        for b in self.blocks {
          if context == Some(Section) && b.context != Section {
            continue
          }
          recurse(b, context)
        }
      }
    Dlist =>
      if any_context || context != Some(Section) {
        for item in self.dlist_items {
          for t in item.terms {
            recurse(t, context)
          }
          if item.desc is Some(d) {
            recurse(d, context)
          }
        }
      }
    Table => {
      let rows = self.table_data().rows
      if traverse_documents {
        for r in rows.head {
          for c in r {
            recurse(c, context)
          }
        }
        let ctx2 = if context == Some(Custom("inner_document")) {
          Some(Document)
        } else {
          context
        }
        for section in [rows.body, rows.foot] {
          for r in section {
            for c in r {
              recurse(c, ctx2)
              if c.style == Some("asciidoc") {
                match c.cell_data().inner_document {
                  Some(d) => recurse(d, ctx2)
                  None => ()
                }
              }
            }
          }
        }
      } else {
        for section in [rows.head, rows.body, rows.foot] {
          for r in section {
            for c in r {
              recurse(c, context)
            }
          }
        }
      }
    }
    _ =>
      for b in self.blocks {
        if context == Some(Section) && b.context != Section {
          continue
        }
        recurse(b, context)
      }
  }
}

// ---------------------------------------------------------------------------
// Constructors

///|
/// Creates a block (Ruby `Block.new parent, context, opts`).
pub fn Node::new_block(
  parent : Node,
  context : Context,
  content_model? : ContentModel,
  subs? : BlockSubs,
  source? : Array[String],
  source_text? : String,
  attributes? : Attributes,
) -> Node {
  let b = Node::alloc(Some(parent), context, attributes?)
  b.content_model = match content_model {
    Some(cm) => cm
    None => default_content_model(context)
  }
  match subs {
    None => ()
    Some(DefaultSubs(ds)) => {
      b.default_subs = ds
      b.commit_subs()
    }
    Some(ExplicitSubs(list)) => {
      b.default_subs = Some(list.copy())
      b.attributes.remove("subs") |> ignore
      b.commit_subs()
    }
    Some(SubsSpec(spec)) => {
      b.default_subs = None
      b.attributes.set_str("subs", spec)
      b.commit_subs()
    }
    Some(NoSubs) => {
      b.default_subs = Some([])
      b.attributes.remove("subs") |> ignore
    }
  }
  match source {
    Some(lines) => b.lines = lines.copy()
    None =>
      match source_text {
        Some(t) if t != "" => b.lines = prepare_source_string(t)
        _ => ()
      }
  }
  b
}

///|
/// How the subs of a new block are specified (Ruby `opts[:subs]`).
pub(all) enum BlockSubs {
  DefaultSubs(Array[Sub]?) // :default with optional default_subs
  ExplicitSubs(Array[Sub]) // an Array of subs
  SubsSpec(String) // a subs attribute value
  NoSubs // nil / false
}

///|
fn default_content_model(context : Context) -> ContentModel {
  match context {
    Audio | Image | PageBreak | ThematicBreak | Video => Empty
    Listing | Literal => Verbatim
    Stem | Pass => Raw
    Open => Compound
    _ => Simple
  }
}

///|
/// Creates a section (Ruby `Section.new parent, level, numbered`).
pub fn Node::new_section(
  parent : Node?,
  level? : Int,
  numbered? : Bool = false,
  attributes? : Attributes,
) -> Node {
  let s = Node::alloc(parent, Section, attributes?)
  match parent {
    Some(p) if p.context == Section => {
      s.level = level.unwrap_or(p.level + 1)
      s.special = p.special
    }
    _ => s.level = level.unwrap_or(1)
  }
  s.numbered = if numbered { Numbered } else { NotNumbered }
  s
}

///|
/// Creates a list (Ruby `List.new parent, context`).
pub fn Node::new_list(
  parent : Node,
  context : Context,
  attributes? : Attributes,
) -> Node {
  Node::alloc(Some(parent), context, attributes?)
}

///|
/// Creates a list item (Ruby `ListItem.new list, text`).
pub fn Node::new_list_item(parent : Node, text? : String) -> Node {
  let li = Node::alloc(Some(parent), ListItem)
  li.text_ = text
  li.level = parent.level
  li.subs = normal_subs.copy()
  li
}

///|
/// Creates an inline node (Ruby `Inline.new parent, context, text, opts`).
pub fn Node::new_inline(
  parent : Node,
  context : Context,
  text? : String,
  id? : String,
  type_? : String,
  target? : String,
  attributes? : Attributes,
) -> Node {
  let n = Node::alloc(Some(parent), context, attributes?)
  n.node_name = "inline_\{context.name()}"
  n.text_ = text
  n.id = id
  n.inline_type = type_
  n.target = target
  n.level = 0
  n
}

// ---------------------------------------------------------------------------
// ListItem / Inline text

///|
/// The raw text of a list item, inline node or table cell.
pub fn Node::raw_text(self : Node) -> String? {
  match self.cell_ {
    Some(c) => Some(c.text)
    None => self.text_
  }
}

///|
pub fn Node::set_text(self : Node, text : String?) -> Unit {
  match self.cell_ {
    Some(c) => c.text = text.unwrap_or("")
    None => self.text_ = text
  }
}

///|
/// Converted text: for list items and cells the text with subs applied; for
/// inline nodes the raw text.
pub fn Node::text(self : Node) -> String? {
  if self.is_inline() {
    return self.text_
  }
  match self.cell_ {
    Some(c) => Some(self.apply_subs(c.text, self.subs))
    None =>
      match self.text_ {
        Some(t) => Some(self.apply_subs(t, self.subs))
        None => None
      }
  }
}

///|
pub fn Node::has_text(self : Node) -> Bool {
  match self.text_ {
    Some(t) => t != ""
    None => false
  }
}

///|
/// Whether a list item is simple (no blocks, or only a nested outline list).
pub fn Node::is_simple(self : Node) -> Bool {
  self.blocks.is_empty() ||
  (self.blocks.length() == 1 && self.blocks[0].is_outline())
}

///|
pub fn Node::is_compound(self : Node) -> Bool {
  !self.is_simple()
}

///|
/// Folds the first block into the text of a list item.
pub fn Node::fold_first(self : Node) -> Unit {
  let first = self.blocks.remove(0)
  self.text_ = match self.text_ {
    Some(t) if t != "" => Some("\{t}\n\{first.source()}")
    _ => Some(first.source())
  }
}

///|
/// The items of a list (for dlists, use `dlist_items`).
pub fn Node::items(self : Node) -> Array[Node] {
  self.blocks
}