///|
fn prune_empty_node_is_effectively_empty(
  node : @dom.Node,
  strip_whitespace : Bool,
) -> Bool {
  if node.kind != Element {
    return false
  }
  if node.ns == Some("html") && @ser.is_void_element(node.name) {
    return false
  }
  match node.template_contents {
    Some(contents) =>
      if !prune_children_are_effectively_empty(contents, strip_whitespace) {
        return false
      }
    None => ()
  }
  prune_children_are_effectively_empty(node, strip_whitespace)
}

///|
fn prune_children_are_effectively_empty(
  node : @dom.Node,
  strip_whitespace : Bool,
) -> Bool {
  for child in node.children {
    match child.kind {
      Text =>
        if strip_whitespace {
          if !child.data[:].trim().is_empty() {
            return false
          }
        } else if child.data != "" {
          return false
        }
      Comment | Doctype => ()
      Element | Document | Fragment => return false
    }
  }
  true
}

///|
fn apply_prune_empty_transform(
  root : @dom.Node,
  transform : TransformSpec,
  limits : @sel.SelectorLimits,
) -> Unit raise @core.HtmlError {
  apply_prune_empty_transforms(root, [transform], limits)
}

///|
fn apply_prune_empty_transforms(
  root : @dom.Node,
  transforms : Array[TransformSpec],
  limits : @sel.SelectorLimits,
) -> Unit raise @core.HtmlError {
  match root.kind {
    Document | Fragment | Element =>
      prune_empty_children(root, transforms, limits)
    _ => ()
  }
}

///|
/// Prune empty elements below `root` in post-order: template contents first,
/// then each child's subtree before the child itself is checked. Uses an
/// explicit frame stack instead of recursion; the element whose subtree is
/// being walked is parked in its parent frame's `pending` slot and checked
/// once that subtree walk has finished.
fn prune_empty_children(
  root : @dom.Node,
  transforms : Array[TransformSpec],
  limits : @sel.SelectorLimits,
) -> Unit raise @core.HtmlError {
  let stack : Array[WalkFrame] = [WalkFrame::new(root)]
  while stack.last() is Some(frame) {
    if frame.schedule_template_first(stack) {
      continue
    }
    let parent = frame.node
    let index = frame.index
    if frame.pending is Some(child) {
      frame.pending = None
      if !prune_empty_child_after_walk(parent, index, child, transforms, limits) {
        frame.index = index + 1
      }
      continue
    }
    if index >= parent.children.length() {
      ignore(stack.pop())
      continue
    }
    let child = parent.children[index]
    match child.kind {
      Document | Fragment => {
        frame.index = index + 1
        stack.push(WalkFrame::new(child))
      }
      Element => {
        frame.pending = Some(child)
        stack.push(WalkFrame::new(child))
      }
      _ => frame.index = index + 1
    }
  }
}

///|
/// Post-order step of `prune_empty_children`: remove `child` (at `index` in
/// `parent`) when the first matching transform finds it effectively empty.
/// Returns whether it was removed.
fn prune_empty_child_after_walk(
  parent : @dom.Node,
  index : Int32,
  child : @dom.Node,
  transforms : Array[TransformSpec],
  limits : @sel.SelectorLimits,
) -> Bool raise @core.HtmlError {
  for transform in transforms {
    if transform_matches_element(child, transform, limits) &&
      prune_empty_node_is_effectively_empty(child, transform.strip_whitespace) {
      transform_call_hook(child, transform)
      let name = @syn.lower_ascii(child.name)
      transform_report(
        transform,
        "Pruned empty <\{name}> (matched selector '\{transform.selector}')",
        Some(child),
      )
      transform_remove_child_at(parent, index)
      return true
    }
  }
  false
}