///|
fn Parser::append_text(self : Parser, raw : StringView, start : Int) -> Unit {
  if raw.is_empty() {
    return
  }
  let mut text_start = start
  let mut cleaned = if self.current_node_uses_foreign_content_for_text() {
    self.clean_foreign_content_text(raw, start)
  } else {
    self.remove_null_characters(raw, start)
  }
  cleaned = normalize_tree_builder_text(cleaned)
  if cleaned.is_empty() {
    return
  }
  if text_has_non_ascii_whitespace(cleaned) {
    self.before_html_phase = false
  }
  if self.current_is_disabled_head_noscript() &&
    text_has_non_ascii_whitespace(cleaned) {
    let whitespace = leading_ascii_whitespace(cleaned)
    self.error_at("unexpected-start-tag", start + whitespace.length())
    ignore(self.pop_open_element())
    if !whitespace.is_empty() {
      let data = if self.xml_coercion {
        coerce_text_for_xml(whitespace)[:]
      } else {
        whitespace
      }
      self.current_node().append_child(self.text_at(data, start))
    }
    if self.current_is_document_head() {
      ignore(self.pop_open_element())
    }
    cleaned = cleaned[whitespace.length():]
    text_start += whitespace.length()
  }
  if self.current_is_document_head() && text_has_non_ascii_whitespace(cleaned) {
    let whitespace = leading_ascii_whitespace(cleaned)
    if !whitespace.is_empty() {
      let data = if self.xml_coercion {
        coerce_text_for_xml(whitespace)[:]
      } else {
        whitespace
      }
      self.current_node().append_child(self.text_at(data, text_start))
    }
    ignore(self.pop_open_element())
    cleaned = cleaned[whitespace.length():]
    text_start += whitespace.length()
  }
  if self.uses_document_frameset_rules() &&
    !self.frameset_reprocesses_in_body_mode &&
    (
      self.after_frameset ||
      self.detached_frameset_mode ||
      self.has_open_element("frameset")
    ) {
    let whitespace = ascii_whitespace_chars_only(cleaned)
    if self.after_frameset && whitespace.length() != cleaned.length() {
      self.error_at("unexpected-token-after-frameset", start + raw.length() - 1)
    }
    if !whitespace.is_empty() {
      self.current_node().append_child(self.text_at(whitespace, text_start))
    }
    return
  }
  if self.uses_document_frameset_rules() && text_clears_frameset_ok(raw) {
    self.frameset_ok = false
  }
  if self.handle_template_column_group_text(cleaned, text_start) {
    return
  }
  if self.handle_fragment_context_colgroup_text(cleaned, text_start) {
    return
  }
  if self.handle_column_group_text(cleaned, text_start) {
    return
  }
  self.report_foreign_table_row_recovery_text(cleaned, text_start)
  if self.foster_parent_table_text(cleaned, text_start) {
    return
  }
  if self.foster_parent_template_table_text(cleaned, text_start) {
    return
  }
  if self.template_table_context_mode_active() &&
    text_has_non_ascii_whitespace(cleaned) {
    ignore(self.report_foster_parenting_character_errors(cleaned, text_start))
  }
  self.report_foster_parented_table_context_text(cleaned, text_start)
  let decoded_result = @tok.decode_entities_with_errors(
    cleaned,
    in_attribute=false,
  )
  for error in decoded_result.errors {
    self.error_at(error.code, text_start + error.offset)
  }
  let decoded = normalize_tree_builder_text(decoded_result.data)
  let mut data = if self.xml_coercion {
    coerce_text_for_xml(decoded)[:]
  } else {
    decoded
  }
  if self.pending_initial_lf_skip {
    self.pending_initial_lf_skip = false
    if data.has_prefix("\n") {
      data = data[1:]
      if data.is_empty() {
        // The text was only the skipped newline; nothing to insert.
        return
      }
      // Advance the recorded origin past the consumed character
      // reference so tracked locations point at the remaining text.
      text_start = text_start + initial_newline_reference_length(cleaned)
    }
  }
  if self.fragment_context_html_after_body &&
    text_has_non_ascii_whitespace(data) {
    // The whitespace decision uses the decoded characters: an entity
    // that decodes to whitespace does not leave after-body mode.
    self.fragment_context_html_after_body = false
    self.fragment_context_html_entered_body = true
  }
  let table_parent = self.current_node()
  if !text_has_non_ascii_whitespace(data) &&
    table_parent.kind == Element &&
    is_table_start_foster_target(table_parent.name) &&
    self.last_open_table_index() >= 0 {
    // Whitespace-only table text inserts at the current table position
    // without reconstructing fostered formatting.
    if self.merge_into_trailing_text(table_parent, data) {
      return
    }
    table_parent.append_child(self.text_at(data, text_start))
    return
  }
  self.reconstruct_active_formatting_elements()
  let parent = self.current_node()
  if self.document_after_body &&
    !self.is_fragment_parser() &&
    !self.in_frameset_document() &&
    text_has_non_ascii_whitespace(data) {
    // Non-whitespace after-body text reprocesses in body wherever the
    // insertion point is; parsing stays in body afterwards.
    self.document_entered_body = true
    self.document_entered_body_from_after_body = true
    self.document_after_body = false
    self.document_after_html = false
  }
  if self.merge_into_trailing_text(parent, data) {
    return
  }
  let text_node = self.text_at(data, text_start)
  parent.append_child(text_node)
  self.mark_forced_body_node(text_node)
  if self.document_head_closed &&
    !self.is_fragment_parser() &&
    is_whitespace_text_node(text_node) &&
    (
      parent.kind == Document ||
      (parent.kind == Element && parent.name == "html")
    ) &&
    !document_children_contain_body_content(self.root) {
    // Whitespace after  belongs between the head and the body.
    self.after_head_whitespace.push(text_node)
  }
}

///|
/// Appends character data to an existing trailing text node, mirroring the
/// spec's "insert the characters into that text node" step. Keeps the
/// original node's source origin and never touches sanitizer escape text.
fn Parser::merge_into_trailing_text(
  self : Parser,
  parent : @dom.Node,
  data : StringView,
) -> Bool {
  match parent.children.last() {
    Some(last) if last.kind == Text &&
      !last.sanitize_escape_only &&
      !self.merge_crosses_body_boundary(parent, last, data) => {
      match self.pending_text_node {
        Some(pending) if physical_equal(pending, last) => ()
        _ => {
          // Start (or restart) a batch on this node. The tree keeps the
          // node with its current data; the builder accumulates the full
          // text and flush_pending_text materializes it once, keeping
          // merges amortized O(n) instead of O(n^2).
          self.flush_pending_text()
          self.pending_text_node = Some(last)
          self.pending_text_builder = StringBuilder()
          self.pending_text_builder.write_string(last.data)
          self.pending_text_is_whitespace = is_whitespace_text_node(last)
        }
      }
      self.pending_text_builder.write_view(data)
      if self.pending_text_is_whitespace && text_has_non_ascii_whitespace(data) {
        self.pending_text_is_whitespace = false
      }
      // Non-whitespace data is the after-body "anything else" case even
      // when it merges into an existing forced text node: the mode
      // switches back to in-body, so later comments insert normally.
      if self.document_after_body &&
        !self.is_fragment_parser() &&
        !self.in_frameset_document() &&
        text_has_non_ascii_whitespace(data) {
        self.document_entered_body = true
        self.document_entered_body_from_after_body = true
        self.document_after_body = false
        self.document_after_html = false
      }
      true
    }
    _ => false
  }
}

///|
/// Materializes a batched trailing text node: replaces the stale node with
/// one carrying the accumulated data, preserving its position, origin, and
/// scaffolder markers. Runs at the end of parsing and whenever a batch
/// moves to a different node.
fn Parser::flush_pending_text(self : Parser) -> Unit {
  let node = match self.pending_text_node {
    Some(node) => node
    None => return
  }
  self.pending_text_node = None
  let full = self.pending_text_builder.to_string()
  if full.length() == node.data.length() {
    return
  }
  let merged = @dom.text(full)
  merged.parsed_from_source = node.parsed_from_source
  merged.origin_offset = node.origin_offset
  merged.origin_line = node.origin_line
  merged.origin_col = node.origin_col
  match node.parent {
    Some(parent) => {
      let mut index = parent.children.length()
      while index > 0 {
        index -= 1
        if physical_equal(parent.children[index], node) {
          parent.children[index] = merged
          merged.parent = Some(parent)
          node.parent = None
          break
        }
      }
    }
    None => ()
  }
  if self.remove_forced_body_node(node) {
    self.forced_body_nodes.push(merged)
  }
  if self.remove_foster_parented_text(node) {
    self.foster_parented_texts.push(merged)
  }
  if node_array_contains_identity(self.after_head_whitespace, node) {
    remove_parser_node_identity(self.after_head_whitespace, node)
    if is_whitespace_text_node(merged) {
      self.after_head_whitespace.push(merged)
    }
  }
}

///|
/// True when appending would merge body text into a top-level
/// whitespace-only run that the scaffolder must keep between the head and
/// the body (for example ` X` keeps the space at the
/// html level and files only `X` into the body). Whitespace merges into
/// whitespace, non-whitespace text was already body content (XX
/// yields "XX"), and runs already marked as body content always accept
/// more data.
fn Parser::merge_crosses_body_boundary(
  self : Parser,
  parent : @dom.Node,
  last : @dom.Node,
  data : StringView,
) -> Bool {
  // The html fragment context scaffolds head/body like a document, so the
  // boundary applies there too; other fragment contexts have no head/body
  // split.
  if self.is_fragment_parser() && !self.fragment_context_html {
    return false
  }
  if !(parent.kind == Document ||
    (parent.kind == Element && parent.name == "html")) {
    return false
  }
  if !self.trailing_text_is_whitespace(last) || self.node_is_forced_body(last) {
    return false
  }
  // After the body has closed, even whitespace belongs to the body (the
  // in-body rules reprocess it), so it must not merge into an unmarked
  // pre-body run either. Similarly, whitespace after  must not
  // merge into a pre-head run: it stays between the head and the body.
  text_has_non_ascii_whitespace(data) ||
  self.document_after_body ||
  (
    self.document_head_closed &&
    !node_array_contains_identity(self.after_head_whitespace, last)
  )
}

///|
/// Whitespace check that accounts for batched data not yet materialized
/// into the tree node.
fn Parser::trailing_text_is_whitespace(self : Parser, node : @dom.Node) -> Bool {
  match self.pending_text_node {
    Some(pending) if physical_equal(pending, node) =>
      self.pending_text_is_whitespace
    _ => is_whitespace_text_node(node)
  }
}

///|
fn Parser::node_is_forced_body(self : Parser, node : @dom.Node) -> Bool {
  self.forced_body_nodes[:].any(candidate => physical_equal(candidate, node))
}

///|
fn Parser::remove_forced_body_node(self : Parser, node : @dom.Node) -> Bool {
  let mut index = 0
  while index < self.forced_body_nodes.length() {
    if physical_equal(self.forced_body_nodes[index], node) {
      ignore(self.forced_body_nodes.remove(index))
      return true
    }
    index += 1
  }
  false
}

///|
fn Parser::remove_foster_parented_text(self : Parser, node : @dom.Node) -> Bool {
  let mut index = 0
  while index < self.foster_parented_texts.length() {
    if physical_equal(self.foster_parented_texts[index], node) {
      ignore(self.foster_parented_texts.remove(index))
      return true
    }
    index += 1
  }
  false
}

///|
fn Parser::insert_body_mode_node(self : Parser, node : @dom.Node) -> Unit {
  let table_index = self.table_context_without_cell_or_caption()
  if table_index >= 0 {
    let current = self.current_node()
    if current.kind == Element && is_table_start_foster_target(current.name) {
      if self.table_index_is_fragment_context_table(table_index) {
        current.append_child(node)
        return
      }
      self.insert_node_before_open_table(node, table_index)
      return
    }
  }
  self.current_node().append_child(node)
  self.mark_forced_body_node(node)
}

///|
fn text_has_non_ascii_whitespace(value : StringView) -> Bool {
  value.any(ch => !ch.is_ascii_whitespace())
}

///|
fn ascii_whitespace_chars_only(value : StringView) -> String {
  let out = StringBuilder(size_hint=value.length())
  for ch in value {
    if ch.is_ascii_whitespace() {
      out.write_char(ch)
    }
  }
  out.to_string()
}

///|
fn normalize_tree_builder_text(value : StringView) -> StringView {
  // The common case has no form feeds: the input view passes through
  // unchanged and the per-character rebuild below is skipped.
  if !value.contains_char('\u{000C}') {
    return value
  }
  let out = StringBuilder(size_hint=value.length())
  for ch in value {
    if ch == '\u{000C}' {
      out.write_char(' ')
    } else {
      out.write_char(ch)
    }
  }
  out.to_string()
}

///|
/// Input-stream preprocessing for raw character data (CDATA sections):
/// CR and CRLF normalize to LF before any further handling. Decoded
/// character references are exempt — they happen after preprocessing.
fn normalize_raw_newlines(value : StringView) -> StringView {
  // The common case has no carriage returns: the input view passes
  // through unchanged.
  if !value.contains_char('\r') {
    return value
  }
  let out = StringBuilder(size_hint=value.length())
  let mut last_was_cr = false
  for ch in value {
    if ch == '\r' {
      out.write_char('\n')
      last_was_cr = true
    } else if ch == '\n' && last_was_cr {
      last_was_cr = false
    } else {
      out.write_char(ch)
      last_was_cr = false
    }
  }
  out.to_string()
}

///|
fn decode_entities_for_tree_text(value : StringView) -> StringView {
  let decoded = @tok.decode_entities(value, in_attribute=false)
  normalize_tree_builder_text(decoded)
}

///|
fn Parser::remove_null_characters(
  self : Parser,
  raw : StringView,
  start : Int,
) -> StringView {
  // The common case has no nulls: the input view passes through
  // unchanged.
  if !raw.contains_char('\u{0000}') {
    return raw
  }
  let out = StringBuilder(size_hint=raw.length())
  let mut pos = 0
  while pos < raw.length() {
    let ch = raw.get_char(pos).unwrap()
    match ch {
      '\u{0000}' => {
        self.error_at("unexpected-null-character", start + pos)
        pos += 1
      }
      _ => {
        out.write_char(ch)
        pos += ch.utf16_len()
      }
    }
  }
  out.to_string()
}

///|
fn Parser::replace_null_characters(
  self : Parser,
  raw : StringView,
  start : Int,
) -> StringView {
  // The common case has no nulls: the input view passes through
  // unchanged.
  if !raw.contains_char('\u{0000}') {
    return raw
  }
  let out = StringBuilder(size_hint=raw.length())
  let mut pos = 0
  while pos < raw.length() {
    let ch = raw.get_char(pos).unwrap()
    match ch {
      '\u{0000}' => {
        self.error_at("unexpected-null-character", start + pos)
        out.write_char('\u{FFFD}')
        pos += 1
      }
      _ => {
        out.write_char(ch)
        pos += ch.utf16_len()
      }
    }
  }
  out.to_string()
}

///|
/// Length of the leading character reference that decoded to the skipped
/// newline: '&' then either '#' digits / '#x' hex digits (numeric) or
/// letters (named), with an optional terminating semicolon.
fn initial_newline_reference_length(text : StringView) -> Int {
  let mut length = 0
  let mut numeric = false
  let mut hex = false
  for ch in text {
    if length == 0 {
      length += 1 // the '&'
      continue
    }
    if ch == ';' {
      length += 1
      break
    }
    if length == 1 && ch == '#' {
      numeric = true
      length += 1
      continue
    }
    if numeric && length == 2 && (ch == 'x' || ch == 'X') {
      hex = true
      length += 1
      continue
    }
    let in_body = if hex {
      ch.is_ascii_hexdigit()
    } else if numeric {
      ch.is_ascii_digit()
    } else {
      ch.is_ascii_alphabetic() || ch.is_ascii_digit()
    }
    if in_body {
      length += 1
    } else {
      break
    }
  }
  length
}

///|
/// Character tokens clear frameset-ok unless they are whitespace; a NUL
/// (replaced by U+FFFD in foreign content) takes the dedicated parse
/// error path that leaves the flag alone.
fn text_clears_frameset_ok(raw : StringView) -> Bool {
  raw.any(ch => !ch.is_ascii_whitespace() && ch != '\u{0}')
}