// L2 plan building: replies and resolution on EXISTING documents.
// Replies are anchorless (the locked K2 policy): a reply splices its
// definition into comments.xml and its linkage into
// word/commentsExtended.xml — creating and wiring that part when
// absent — keyed by LAST-body-paragraph w14:paraId values. Existing
// documents may predate w14, so the RETROFIT allocates a fresh 8-hex
// paraId (collision scope: every paraId in comments.xml and all
// reachable story parts) and splices the attribute into the parent's
// last body paragraph, declaring w14 (+ mc:Ignorable) on the comments
// root when absent. commentsExtended itself is rewritten WHOLE-PART:
// it holds pure linkage (no user content), so the canonical
// re-serialization is the declared edit span.

///|
/// One commentsExtended record, in part order.
priv struct CommentExEntry {
  para_id : String
  done : Bool
  parent_para_id : String?
}

///|
fn default_annotation_planner_xml_budget() -> @xml.XmlReadBudget {
  @xml.xml_read_budget(
    max_source_units=16 * 1024 * 1024,
    max_tokens=262_144,
    max_materialized_chars=8 * 1024 * 1024,
    max_token_chars=4 * 1024 * 1024,
  )
}

///|
/// Builds the splice plan for `docx annotate reply`: the reply's
/// definition (paraId-stamped) into comments.xml plus the whole-part
/// commentsExtended update (parent retrofit included). Returns the
/// plan and the reply's new comment id.
pub fn plan_comment_reply(
  annotated : DocxAnnotatedResult,
  original : BytesView,
  parent_id~ : String,
  spec : CommentSpec,
  max_fragment_bytes? : Int = 8 * 1024 * 1024,
) -> (@splice.SplicePlan, String) raise DocxError {
  let xml_budget = default_annotation_planner_xml_budget()
  plan_comment_reply_zip(
    annotated,
    open_zip(original),
    parent_id~,
    spec,
    max_fragment_bytes,
    xml_budget,
  )
}

///|
/// Archive-backed reply planner for bounded preservation sessions.
pub fn plan_comment_reply_archive(
  annotated : DocxAnnotatedResult,
  archive : @mbtzip.Archive,
  parent_id~ : String,
  spec : CommentSpec,
  xml_budget? : @xml.XmlReadBudget,
  max_fragment_bytes? : Int = 8 * 1024 * 1024,
) -> (@splice.SplicePlan, String) raise DocxError {
  let xml_budget = match xml_budget {
    Some(value) => value
    None => default_annotation_planner_xml_budget()
  }
  plan_comment_reply_zip(
    annotated,
    open_zip_archive(archive),
    parent_id~,
    spec,
    max_fragment_bytes,
    xml_budget,
  )
}

///|
fn plan_comment_reply_zip(
  annotated : DocxAnnotatedResult,
  zip : ZipArchive,
  parent_id~ : String,
  spec : CommentSpec,
  max_fragment_bytes : Int,
  xml_budget : @xml.XmlReadBudget,
) -> (@splice.SplicePlan, String) raise DocxError {
  check_mutation_gates(annotated)
  let plan = @splice.SplicePlan::new()
  let parent = find_defined_comment(annotated, parent_id)
  if parent.body_paragraphs() == 0 {
    raise Unsupported(
      message="comment '\{parent_id}' has no body paragraph to key the thread on; refusing to reply",
    )
  }
  guard annotated.comments_part() is Some(comments_part) else {
    raise Unsupported(
      message="the document has no wired comments part; refusing to reply",
    )
  }
  guard zip.read_bytes(comments_part) is Some(comments_bytes) else {
    raise Unsupported(message="the comments part is unreadable")
  }
  // Fresh paraIds: collision scope is every paraId in the reachable
  // parts (duplicates there are unrepairable and fail closed).
  let used_para_ids = collect_para_ids(
    zip,
    annotated,
    Some(comments_part),
    xml_budget,
  )
  let mut next_para_seed = 1
  fn allocate_para_id() -> String {
    let mut candidate = format_para_id(next_para_seed)
    while used_para_ids.contains(canonical_para_id(candidate)) {
      next_para_seed += 1
      candidate = format_para_id(next_para_seed)
    }
    next_para_seed += 1
    used_para_ids.add(canonical_para_id(candidate))
    candidate
  }
  // The comments root: which prefix carries the w14 URI (or a root
  // retrofit that declares it).
  guard annotated.story_root_span("/comments") is Some(root_span) else {
    raise Unsupported(
      message="the comments part could not be scanned; refusing to reply",
    )
  }
  let w14_prefix = ensure_w14_on_root(
    plan, comments_part, comments_bytes, root_span,
  )
  // Parent retrofit: the parent's LAST body paragraph needs a paraId.
  let parent_para_id = match parent.last_para_id() {
    Some(existing) => existing
    None => {
      let fresh = allocate_para_id()
      let ordinal = comment_ordinal(annotated, parent_id)
      let last_paragraph = parent.body_paragraphs()
      guard annotated.story_paragraph_span(
          "/comments",
          "comment[\{ordinal}]/p[\{last_paragraph}]",
        )
        is Some(paragraph_span) else {
        raise Unsupported(
          message="comment '\{parent_id}' has no scannable last body paragraph; refusing to reply",
        )
      }
      insert_open_tag_attribute(
        plan,
        comments_part,
        comments_bytes,
        paragraph_span,
        " \{w14_prefix}:paraId=\"\{fresh}\"",
      )
      fresh
    }
  }
  // The reply definition: dense id, every body paragraph stamped, the
  // LAST stamp being the reply's commentEx key.
  check_comment_body_for_fragment(spec.body, max_fragment_bytes)
  let new_id = allocate_comment_id(annotated)
  let reply_para_ids : Array[String] = []
  for _ in 0..
      plan.edit_part(
        comments_part,
        @splice.span_edit(
          start=insert_at,
          end=insert_at,
          @utf8.encode(definition),
        ),
      )
    None =>
      // A defined parent implies a populated, open-form root.
      raise Unsupported(
        message="the comments part has a self-closing root yet holds definitions; refusing to reply",
      )
  }
  guard reply_para_ids.last() is Some(reply_key) else {
    raise Unsupported(message="the reply body is empty")
  }
  // commentsExtended: ENTRY-LEVEL edits — a parent entry is created
  // only when missing (an existing one keeps its bytes, resolution
  // state included), and the reply's entry appends with paraIdParent
  // linking the thread.
  let additions : Array[CommentExEntry] = []
  let mut parent_seen = false
  match annotated.comments_extended_part() {
    Some(part) =>
      match zip.read_bytes(part) {
        Some(bytes) => {
          let (locations, _, _) = scan_comment_ex_part(bytes)
          for location in locations {
            if location.para_id == canonical_para_id(parent_para_id) {
              parent_seen = true
            }
          }
        }
        None =>
          raise Unsupported(message="the commentsExtended part is unreadable")
      }
    None => ()
  }
  if !parent_seen {
    additions.push({
      para_id: canonical_para_id(parent_para_id),
      done: false,
      parent_para_id: None,
    })
  }
  additions.push({
    para_id: canonical_para_id(reply_key),
    done: false,
    parent_para_id: Some(canonical_para_id(parent_para_id)),
  })
  queue_comments_extended_edits(
    plan,
    zip,
    annotated,
    None,
    additions,
    xml_budget,
  )
  pin_splice_sources(plan, zip)
  (plan, new_id)
}

///|
/// Builds the splice plan for `docx annotate resolve|unresolve`: the
/// comment's commentsExtended entry flips (or gains) its w15:done flag,
/// with the same paraId retrofit as replies when the definition is
/// unstamped.
pub fn plan_comment_resolution(
  annotated : DocxAnnotatedResult,
  original : BytesView,
  comment_id~ : String,
  done~ : Bool,
) -> @splice.SplicePlan raise DocxError {
  plan_comment_resolution_zip(
    annotated,
    open_zip(original),
    comment_id~,
    done~,
    default_annotation_planner_xml_budget(),
  )
}

///|
/// Archive-backed resolution planner for bounded preservation sessions.
pub fn plan_comment_resolution_archive(
  annotated : DocxAnnotatedResult,
  archive : @mbtzip.Archive,
  comment_id~ : String,
  done~ : Bool,
  xml_budget? : @xml.XmlReadBudget,
) -> @splice.SplicePlan raise DocxError {
  let xml_budget = match xml_budget {
    Some(value) => value
    None => default_annotation_planner_xml_budget()
  }
  plan_comment_resolution_zip(
    annotated,
    open_zip_archive(archive),
    comment_id~,
    done~,
    xml_budget,
  )
}

///|
fn plan_comment_resolution_zip(
  annotated : DocxAnnotatedResult,
  zip : ZipArchive,
  comment_id~ : String,
  done~ : Bool,
  xml_budget : @xml.XmlReadBudget,
) -> @splice.SplicePlan raise DocxError {
  check_mutation_gates(annotated)
  let plan = @splice.SplicePlan::new()
  let comment = find_defined_comment(annotated, comment_id)
  if comment.body_paragraphs() == 0 {
    raise Unsupported(
      message="comment '\{comment_id}' has no body paragraph to key resolution on; refusing",
    )
  }
  guard annotated.comments_part() is Some(comments_part) else {
    raise Unsupported(
      message="the document has no wired comments part; refusing",
    )
  }
  guard zip.read_bytes(comments_part) is Some(comments_bytes) else {
    raise Unsupported(message="the comments part is unreadable")
  }
  // The duplicate-paraId gate runs UNCONDITIONALLY (review round 1):
  // resolving on an ambiguous key would mutate the wrong thread.
  let used_para_ids = collect_para_ids(
    zip,
    annotated,
    Some(comments_part),
    xml_budget,
  )
  let key = match comment.last_para_id() {
    Some(existing) => existing
    None => {
      let mut seed = 1
      let mut fresh = format_para_id(seed)
      while used_para_ids.contains(canonical_para_id(fresh)) {
        seed += 1
        fresh = format_para_id(seed)
      }
      guard annotated.story_root_span("/comments") is Some(root_span) else {
        raise Unsupported(
          message="the comments part could not be scanned; refusing",
        )
      }
      let w14_prefix = ensure_w14_on_root(
        plan, comments_part, comments_bytes, root_span,
      )
      let ordinal = comment_ordinal(annotated, comment_id)
      let last_paragraph = comment.body_paragraphs()
      guard annotated.story_paragraph_span(
          "/comments",
          "comment[\{ordinal}]/p[\{last_paragraph}]",
        )
        is Some(paragraph_span) else {
        raise Unsupported(
          message="comment '\{comment_id}' has no scannable last body paragraph; refusing",
        )
      }
      insert_open_tag_attribute(
        plan,
        comments_part,
        comments_bytes,
        paragraph_span,
        " \{w14_prefix}:paraId=\"\{fresh}\"",
      )
      fresh
    }
  }
  queue_comments_extended_edits(
    plan,
    zip,
    annotated,
    Some((key, done)),
    [],
    xml_budget,
  )
  pin_splice_sources(plan, zip)
  plan
}

///|
fn find_defined_comment(
  annotated : DocxAnnotatedResult,
  id : String,
) -> CommentInfo raise DocxError {
  for comment in annotated.annotations().comments() {
    if comment.id() == id {
      if !comment.defined() {
        raise Unsupported(
          message="comment '\{id}' has markers but no definition; refusing",
        )
      }
      return comment
    }
  }
  raise Unsupported(message="the document has no comment with id '\{id}'")
}

///|
/// The parent's 1-based ordinal among DEFINITIONS in part order (the
/// scanner's comment[k] paths follow the same order).
fn comment_ordinal(
  annotated : DocxAnnotatedResult,
  id : String,
) -> Int raise DocxError {
  for index, pair in annotated.comment_bodies() {
    let (comment_id, _) = pair
    if comment_id == id {
      return index + 1
    }
  }
  raise Unsupported(message="the document has no comment with id '\{id}'")
}

///|
fn spec_body_length(spec : CommentSpec) -> Int {
  spec.body.length()
}

///|
/// Dense comment-id allocation (shared semantics with
/// plan_comment_addition, including the numeric-identity rules).
fn allocate_comment_id(
  annotated : DocxAnnotatedResult,
) -> String raise DocxError {
  let used_numeric : Set[Int] = Set([])
  let mut max_numeric = -1
  for comment in annotated.annotations().comments() {
    match canonical_comment_id(comment.id()) {
      CanonicalNumeric(value) => {
        used_numeric.add(value)
        if value > max_numeric {
          max_numeric = value
        }
      }
      NonNumeric => ()
      OutOfRange =>
        raise Unsupported(
          message="the document holds comment id '\{comment.id()}', outside the supported numeric range; refusing",
        )
    }
  }
  let mut candidate = max_numeric + 1
  while used_numeric.contains(candidate) {
    candidate += 1
  }
  if candidate > 999_999_999 {
    raise Unsupported(
      message="cannot allocate a comment id: the document already uses the maximum supported id",
    )
  }
  candidate.to_string()
}

///|
/// Every w14:paraId value in the reachable parts (main story, comments,
/// footnotes/endnotes, headers/footers). DUPLICATES anywhere are
/// unrepairable and fail closed — the retrofit's collision scope must
/// be trustworthy.
fn collect_para_ids(
  zip : ZipArchive,
  annotated : DocxAnnotatedResult,
  comments_part : String?,
  xml_budget : @xml.XmlReadBudget,
) -> StableStringSet raise DocxError {
  let para_ids : StableStringSet = SortedSet([])
  let parts : Array[String] = [annotated.main_story_part()]
  let seen_parts : StableStringSet = SortedSet(parts)
  match comments_part {
    Some(part) if !seen_parts.contains(part) => {
      seen_parts.add(part)
      parts.push(part)
    }
    _ => ()
  }
  // Sibling story parts named through the MAIN part's relationships.
  let logical_main = main_story_logical_part(annotated, zip)
  let (main_dir, main_base) = split_part_path(logical_main)
  let rels_part = if main_dir == "" {
    "_rels/\{main_base}.rels"
  } else {
    "\{main_dir}/_rels/\{main_base}.rels"
  }
  let relationships = read_relationships_strict_limited(
    zip, rels_part, xml_budget,
  )
  for
    reachable in resolve_reachable_annotation_story_parts(
      zip, relationships, main_dir,
    ) {
    if !seen_parts.contains(reachable.path) {
      seen_parts.add(reachable.path)
      parts.push(reachable.path)
    }
  }
  for part in parts {
    guard zip.read_bytes(part) is Some(bytes) else {
      raise MissingPart(
        message="a paraId-bearing story relationship targets missing part '\{part}'",
      )
    }
    let root = @xml.read_xml_bytes_strict_limited(
      bytes,
      xml_budget,
      namespace_map=office_namespace_map(),
    ) catch {
      ResourceLimit(..) as error => raise error
      error =>
        raise InvalidXml(
          message="could not parse paraId-bearing story part '\{part}': \{repr(error)}",
        )
    }
    collect_para_id_attributes(root, para_ids) catch {
      Unsupported(message~) =>
        raise Unsupported(message="\{message} (in '\{part}')")
      err => raise err
    }
  }
  para_ids
}

///|
/// Validates every reachable story's `w14:paraId` values under one cumulative
/// XML budget. This is the candidate-side gate for generic splice plans; the
/// reply/resolution planners use the same traversal before allocating IDs.
pub fn validate_global_para_id_state_archive_limited(
  annotated : DocxAnnotatedResult,
  archive : @mbtzip.Archive,
  xml_budget : @xml.XmlReadBudget,
) -> Unit raise DocxError {
  collect_para_ids(
    open_zip_archive(archive),
    annotated,
    annotated.comments_part(),
    xml_budget,
  )
  |> ignore
}

///|
fn collect_para_id_attributes(
  element : XmlElement,
  para_ids : StableStringSet,
) -> Unit raise DocxError {
  match element.attributes.get("w14:paraId") {
    Some(para_id) => {
      guard canonical_para_identity(para_id) is Some(canonical) else {
        raise Unsupported(
          message="invalid w14:paraId; the document's paraId state is not repairable",
        )
      }
      if para_ids.contains(canonical) {
        raise Unsupported(
          message="duplicate w14:paraId '\{para_id}'; the document's paraId state is not repairable",
        )
      }
      para_ids.add(canonical)
    }
    None => ()
  }
  for child in element.children {
    match child {
      XmlElement(inner) => collect_para_id_attributes(inner, para_ids)
      _ => ()
    }
  }
}

///|
/// Ensures the w14 URI is bound on the comments ROOT and that the
/// chosen prefix is listed in mc:Ignorable, returning that prefix.
/// TWO-PASS over the root's attributes (declarations first, then
/// mc:Ignorable through the completed map — attribute ORDER must not
/// matter), and the Ignorable token is ensured even when the URI was
/// already bound (review round 1).
fn ensure_w14_on_root(
  plan : @splice.SplicePlan,
  part : String,
  part_bytes : BytesView,
  root_span : NodeSpan,
) -> String raise DocxError {
  let attributes = scan_open_tag_attributes(part_bytes, root_span.byte_start())
  let all_bindings = collect_namespace_bindings(part_bytes)
  let root_bindings : StableStringMap[String] = SortedMap([])
  // Pass 1: every root namespace declaration, decoded exactly as strict XML
  // sees it. Entity spellings must not create a second semantic binding.
  let mut w14_prefix : String? = None
  let mut mc_prefix : String? = None
  for attribute in attributes.entries {
    let (name, value, _, _) = attribute
    if name.has_prefix("xmlns:") {
      let prefix = name.view(start_offset=6).to_owned()
      let uri = decode_entities(value)
      root_bindings[prefix] = uri
      if uri == W14_NAMESPACE &&
        namespace_prefix_is_stable(all_bindings, prefix, W14_NAMESPACE) &&
        w14_prefix is None {
        w14_prefix = Some(prefix)
      }
      if uri == MC_NAMESPACE && mc_prefix is None {
        mc_prefix = Some(prefix)
      }
    }
  }
  // Pass 2: find the expanded mc:Ignorable attribute through any root prefix
  // bound to MC, not merely the first declaration spelling.
  let mut ignorable : (String, Int)? = None
  for attribute in attributes.entries {
    let (name, value, _, value_end) = attribute
    let (prefix, local_name) = split_qualified(name)
    if prefix != "" &&
      local_name == "Ignorable" &&
      root_bindings.get(prefix) is Some(uri) &&
      uri == MC_NAMESPACE {
      ignorable = Some((value, value_end))
      if mc_prefix is None {
        mc_prefix = Some(prefix)
      }
    }
  }
  let insert_at = attributes.tag_gt -
    (if attributes.self_closing { 1 } else { 0 })
  let chosen = match w14_prefix {
    Some(prefix) => prefix
    None => fresh_namespace_prefix(all_bindings, "w14", "mbtw14")
  }
  let chosen_mc = match mc_prefix {
    Some(prefix) => prefix
    None => fresh_namespace_prefix(all_bindings, "mc", "mbtmc")
  }
  let mut declarations = ""
  if w14_prefix is None {
    declarations += " xmlns:\{chosen}=\"\{W14_NAMESPACE}\""
  }
  if mc_prefix is None {
    declarations += " xmlns:\{chosen_mc}=\"\{MC_NAMESPACE}\""
  }
  if declarations != "" {
    plan.edit_part(
      part,
      @splice.span_edit(
        start=insert_at,
        end=insert_at,
        @utf8.encode(declarations),
      ),
    )
  }
  // The Ignorable token, ensured in every configuration.
  match ignorable {
    Some((value, value_end)) => {
      let mut listed = false
      for token in decode_entities(value).split(" ") {
        if token.to_owned() == chosen {
          listed = true
        }
      }
      if !listed {
        plan.edit_part(
          part,
          @splice.span_edit(
            start=value_end,
            end=value_end,
            @utf8.encode(" \{chosen}"),
          ),
        )
      }
    }
    None =>
      plan.edit_part(
        part,
        @splice.span_edit(
          start=insert_at,
          end=insert_at,
          @utf8.encode(" \{chosen_mc}:Ignorable=\"\{chosen}\""),
        ),
      )
  }
  chosen
}

///|
/// All lexical prefix bindings in a strict-parsed comments part. Keeping every
/// URI observed for a prefix lets the retrofit reject root aliases that are
/// shadowed at the paragraph where the new attribute will be inserted.
fn collect_namespace_bindings(
  bytes : BytesView,
) -> StableStringMap[StableStringSet] raise DocxError {
  let bindings : StableStringMap[StableStringSet] = SortedMap([])
  let limit = bytes.length()
  let mut cursor = 0
  while cursor < limit {
    if bytes[cursor] != b'<' {
      cursor += 1
      continue
    }
    if ascii_bytes_start_with(bytes, cursor, "")
      continue
    }
    if ascii_bytes_start_with(bytes, cursor, "")
      continue
    }
    if ascii_bytes_start_with(bytes, cursor, "")
      continue
    }
    if ascii_bytes_start_with(bytes, cursor, "' {
        cursor += 1
      }
      cursor += 1
      continue
    }
    if ascii_bytes_start_with(bytes, cursor, " existing
          None => {
            let fresh : StableStringSet = SortedSet([])
            bindings[prefix] = fresh
            fresh
          }
        }
        uris.add(decode_entities(value))
      }
    }
    cursor = tag.tag_gt + 1
  }
  bindings
}

///|
fn namespace_prefix_is_stable(
  bindings : StableStringMap[StableStringSet],
  prefix : String,
  expected_uri : String,
) -> Bool {
  guard bindings.get(prefix) is Some(uris) else { return false }
  for uri in uris {
    if uri != expected_uri {
      return false
    }
  }
  true
}

///|
fn fresh_namespace_prefix(
  bindings : StableStringMap[StableStringSet],
  preferred : String,
  fallback : String,
) -> String {
  if !bindings.contains(preferred) {
    return preferred
  }
  if !bindings.contains(fallback) {
    return fallback
  }
  let mut ordinal = 1
  while bindings.contains("\{fallback}\{ordinal}") {
    ordinal += 1
  }
  "\{fallback}\{ordinal}"
}

///|
/// Splices one attribute into a paragraph's OPEN TAG (just before its
/// '>' — quote-aware, so a '>' inside an attribute value cannot fool
/// the scan).
fn insert_open_tag_attribute(
  plan : @splice.SplicePlan,
  part : String,
  part_bytes : BytesView,
  span : NodeSpan,
  attribute_text : String,
) -> Unit raise DocxError {
  let attributes = scan_open_tag_attributes(part_bytes, span.byte_start())
  let insert_at = attributes.tag_gt -
    (if attributes.self_closing { 1 } else { 0 })
  plan.edit_part(
    part,
    @splice.span_edit(
      start=insert_at,
      end=insert_at,
      @utf8.encode(attribute_text),
    ),
  )
}

///|
priv struct OpenTagScan {
  // (name, value, value_start, value_end) per attribute, byte offsets.
  entries : Array[(String, String, Int, Int)]
  // Offset of the closing '>' of the open tag.
  tag_gt : Int
  self_closing : Bool
}

///|
/// Scans one open tag's attributes from raw UTF-8 bytes, quote-aware.
fn scan_open_tag_attributes(
  bytes : BytesView,
  tag_start : Int,
) -> OpenTagScan raise DocxError {
  let entries : Array[(String, String, Int, Int)] = []
  let limit = bytes.length()
  let mut at = tag_start + 1
  fn is_space(byte : Byte) -> Bool {
    byte == b' ' || byte == b'\t' || byte == b'\r' || byte == b'\n'
  }
  // Skip the element name.
  while at < limit &&
        !is_space(bytes[at]) &&
        bytes[at] != b'>' &&
        bytes[at] != b'/' {
    at += 1
  }
  for ;; {
    while at < limit && is_space(bytes[at]) {
      at += 1
    }
    if at >= limit {
      raise Unsupported(message="an unterminated open tag; refusing")
    }
    if bytes[at] == b'>' {
      return { entries, tag_gt: at, self_closing: false }
    }
    if bytes[at] == b'/' && at + 1 < limit && bytes[at + 1] == b'>' {
      return { entries, tag_gt: at + 1, self_closing: true }
    }
    let name_start = at
    while at < limit &&
          !is_space(bytes[at]) &&
          bytes[at] != b'=' &&
          bytes[at] != b'>' {
      at += 1
    }
    let name = utf8_slice(bytes, name_start, at)
    while at < limit && is_space(bytes[at]) {
      at += 1
    }
    if at >= limit || bytes[at] != b'=' {
      raise Unsupported(message="a malformed open tag; refusing")
    }
    at += 1
    while at < limit && is_space(bytes[at]) {
      at += 1
    }
    if at >= limit || !(bytes[at] == b'"' || bytes[at] == b'\'') {
      raise Unsupported(message="a malformed open tag; refusing")
    }
    let quote = bytes[at]
    at += 1
    let value_start = at
    while at < limit && bytes[at] != quote {
      at += 1
    }
    if at >= limit {
      raise Unsupported(message="an unterminated attribute value; refusing")
    }
    entries.push((name, utf8_slice(bytes, value_start, at), value_start, at))
    at += 1
  }
}

///|
/// A located commentEx entry in the EXISTING part: canonical paraId,
/// current done state, the byte span of the done attribute VALUE (when
/// present), and the open tag's insertion offset.
priv struct CommentExLocation {
  para_id : String
  done_value_span : (Int, Int)?
  insert_at : Int
  done_prefix : String
}

///|
/// Canonical paraId identity: ST_LongHexNumber is eight hex digits, so
/// conforming values compare case-insensitively (uppercased); anything
/// else compares verbatim.
fn canonical_para_id(value : String) -> String {
  if value.length() != 8 {
    return value
  }
  let builder = StringBuilder::new()
  for unit in value {
    let code = unit.to_int()
    if code >= '0'.to_int() && code <= '9'.to_int() {
      builder.write_char(code.to_char().unwrap_or('?'))
    } else if code >= 'a'.to_int() && code <= 'f'.to_int() {
      builder.write_char((code - 32).to_char().unwrap_or('?'))
    } else if code >= 'A'.to_int() && code <= 'F'.to_int() {
      builder.write_char(code.to_char().unwrap_or('?'))
    } else {
      return value
    }
  }
  builder.to_string()
}

///|
/// Walks the EXISTING commentsExtended part at the byte level,
/// namespace-aware (root declarations plus each entry's own), and
/// returns the located entries, the offset of the root's close tag
/// (None for a self-closing root), and the root's byte span. Duplicate
/// canonical paraIds fail closed — mutating on ambiguous keys is the
/// unrepairable state the contract names.
fn scan_comment_ex_part(
  bytes : BytesView,
) -> (Array[CommentExLocation], Int?, (Int, Int)) raise DocxError {
  // The root open tag: the first '<' that is not a declaration,
  // comment, or PI.
  let limit = bytes.length()
  let mut at = 0
  let mut root_start = -1
  while at < limit {
    if bytes[at] == b'<' {
      if at + 1 < limit && (bytes[at + 1] == b'?' || bytes[at + 1] == b'!') {
        // Skip the declaration/PI/comment wholesale.
        let close = if bytes[at + 1] == b'?' {
          "?>"
        } else if at + 3 < limit &&
          bytes[at + 1] == b'!' &&
          bytes[at + 2] == b'-' &&
          bytes[at + 3] == b'-' {
          "-->"
        } else {
          ">"
        }
        let mut scan = at + 1
        let terminator = close.code_units()
        while scan <= limit - terminator.length() {
          let mut matched = true
          for offset, unit in terminator {
            if bytes[scan + offset].to_int() != unit.to_int() {
              matched = false
              break
            }
          }
          if matched {
            break
          }
          scan += 1
        }
        at = scan + close.length()
        continue
      }
      root_start = at
      break
    }
    at += 1
  }
  if root_start < 0 {
    raise Unsupported(message="the commentsExtended part has no root element")
  }
  let root_scan = scan_open_tag_attributes(bytes, root_start)
  // Root-level namespace declarations.
  let root_declarations : StableStringMap[String] = SortedMap([])
  for attribute in root_scan.entries {
    let (name, value, _, _) = attribute
    if name == "xmlns" {
      root_declarations[""] = decode_entities(value)
    } else if name.has_prefix("xmlns:") {
      root_declarations[name.view(start_offset=6).to_owned()] = decode_entities(
        value,
      )
    }
  }
  if root_scan.self_closing {
    return ([], None, (root_start, root_scan.tag_gt + 1))
  }
  // Walk depth-1 children.
  let locations : Array[CommentExLocation] = []
  let seen : StableStringSet = SortedSet([])
  let mut cursor = root_scan.tag_gt + 1
  let mut depth = 1
  let mut root_close = -1
  while cursor < limit {
    if bytes[cursor] != b'<' {
      cursor += 1
      continue
    }
    if cursor + 3 < limit &&
      bytes[cursor + 1] == b'!' &&
      bytes[cursor + 2] == b'-' &&
      bytes[cursor + 3] == b'-' {
      let mut scan = cursor + 4
      while scan + 2 < limit {
        if bytes[scan] == b'-' &&
          bytes[scan + 1] == b'-' &&
          bytes[scan + 2] == b'>' {
          break
        }
        scan += 1
      }
      cursor = scan + 3
      continue
    }
    if cursor + 1 < limit && bytes[cursor + 1] == b'?' {
      let mut scan = cursor + 2
      while scan + 1 < limit {
        if bytes[scan] == b'?' && bytes[scan + 1] == b'>' {
          break
        }
        scan += 1
      }
      cursor = scan + 2
      continue
    }
    // CDATA sections (valid between or inside entries) are skipped
    // wholesale — review round 2.
    if cursor + 8 < limit &&
      bytes[cursor + 1] == b'!' &&
      bytes[cursor + 2] == b'[' &&
      bytes[cursor + 3] == b'C' &&
      bytes[cursor + 4] == b'D' &&
      bytes[cursor + 5] == b'A' &&
      bytes[cursor + 6] == b'T' &&
      bytes[cursor + 7] == b'A' &&
      bytes[cursor + 8] == b'[' {
      let mut scan = cursor + 9
      while scan + 2 < limit {
        if bytes[scan] == b']' &&
          bytes[scan + 1] == b']' &&
          bytes[scan + 2] == b'>' {
          break
        }
        scan += 1
      }
      cursor = scan + 3
      continue
    }
    if cursor + 1 < limit && bytes[cursor + 1] == b'/' {
      depth -= 1
      if depth == 0 {
        root_close = cursor
        break
      }
      while cursor < limit && bytes[cursor] != b'>' {
        cursor += 1
      }
      cursor += 1
      continue
    }
    let tag = scan_open_tag_attributes(bytes, cursor)
    if depth == 1 {
      // Resolve this tag against root + its own declarations.
      let own_declarations : StableStringMap[String] = SortedMap([])
      for attribute in tag.entries {
        let (name, value, _, _) = attribute
        if name == "xmlns" {
          own_declarations[""] = decode_entities(value)
        } else if name.has_prefix("xmlns:") {
          own_declarations[name.view(start_offset=6).to_owned()] = decode_entities(
            value,
          )
        }
      }
      fn prefix_uri(prefix : String) -> String? {
        match own_declarations.get(prefix) {
          Some(uri) => Some(uri)
          None => root_declarations.get(prefix)
        }
      }
      let tag_name = utf8_slice(
        bytes,
        cursor + 1,
        cursor + 1 + tag_name_length(bytes, cursor + 1),
      )
      let (tag_prefix, tag_local) = split_qualified(tag_name)
      if prefix_uri(tag_prefix) is Some(uri) &&
        uri == W15_NAMESPACE &&
        tag_local == "commentEx" {
        let mut para_id : String? = None
        let mut done_span : (Int, Int)? = None
        let mut done_prefix = tag_prefix
        for attribute in tag.entries {
          let (name, value, value_start, value_end) = attribute
          let (attr_prefix, attr_local) = split_qualified(name)
          if attr_prefix == "" {
            continue
          }
          if prefix_uri(attr_prefix) is Some(attr_uri) &&
            attr_uri == W15_NAMESPACE {
            if attr_local == "paraId" {
              para_id = Some(canonical_para_id(decode_entities(value)))
              // Unprefixed elements can use the W15 default namespace, but
              // attributes cannot. Reuse the paraId attribute's bound prefix
              // when a missing done attribute must be inserted.
              done_prefix = attr_prefix
            }
            if attr_local == "done" {
              done_span = Some((value_start, value_end))
              done_prefix = attr_prefix
            }
          }
        }
        match para_id {
          Some(key) => {
            if seen.contains(key) {
              raise Unsupported(
                message="duplicate commentsExtended records for paraId '\{key}'; the thread state is not repairable",
              )
            }
            seen.add(key)
            locations.push({
              para_id: key,
              done_value_span: done_span,
              insert_at: tag.tag_gt - (if tag.self_closing { 1 } else { 0 }),
              done_prefix,
            })
          }
          None =>
            raise Unsupported(
              message="a commentsExtended record has no paraId; the thread state is not repairable",
            )
        }
      }
    }
    if !tag.self_closing {
      depth += 1
    }
    cursor = tag.tag_gt + 1
  }
  if root_close < 0 {
    raise Unsupported(
      message="the commentsExtended part's root never closes; refusing",
    )
  }
  (locations, Some(root_close), (root_start, root_scan.tag_gt + 1))
}

///|
fn tag_name_length(bytes : BytesView, name_start : Int) -> Int {
  let mut at = name_start
  while at < bytes.length() &&
        bytes[at] != b' ' &&
        bytes[at] != b'\t' &&
        bytes[at] != b'\r' &&
        bytes[at] != b'\n' &&
        bytes[at] != b'/' &&
        bytes[at] != b'>' {
    at += 1
  }
  at - name_start
}

///|
fn split_qualified(name : String) -> (String, String) {
  match name.find(":") {
    Some(colon) =>
      (
        name.view(end_offset=colon).to_owned(),
        name.view(start_offset=colon + 1).to_owned(),
      )
    None => ("", name)
  }
}

///|
/// A self-contained commentEx fragment (its own xmlns:w15).
fn comment_ex_fragment(entry : CommentExEntry) -> String {
  let attributes : Map[String, String] = {
    "w15:paraId": entry.para_id,
    "w15:done": if entry.done {
      "1"
    } else {
      "0"
    },
  }
  match entry.parent_para_id {
    Some(parent) => attributes["w15:paraIdParent"] = parent
    None => ()
  }
  @xml.write_xml_fragment(@xml.xml_element("w15:commentEx", attributes~), namespaces={
    "w15": W15_NAMESPACE,
  })
}

///|
/// Queues the commentsExtended mutation as ENTRY-LEVEL byte splices —
/// foreign producer content anywhere else in the part stays verbatim
/// (review round 1). `flip` updates one existing entry's done state;
/// `additions` append before the root close (a self-closing root is
/// rewritten by its own extent). When the part does not exist, it is
/// created fresh and wired.
fn queue_comments_extended_edits(
  plan : @splice.SplicePlan,
  zip : ZipArchive,
  annotated : DocxAnnotatedResult,
  flip : (String, Bool)?,
  additions : Array[CommentExEntry],
  xml_budget : @xml.XmlReadBudget,
) -> Unit raise DocxError {
  match annotated.comments_extended_part() {
    Some(part) => {
      guard zip.read_bytes(part) is Some(bytes) else {
        raise Unsupported(message="the commentsExtended part is unreadable")
      }
      let (locations, root_close, root_span) = scan_comment_ex_part(bytes)
      match flip {
        Some((key, done)) => {
          let mut flipped = false
          for location in locations {
            if location.para_id == canonical_para_id(key) {
              flipped = true
              match location.done_value_span {
                Some((value_start, value_end)) =>
                  plan.edit_part(
                    part,
                    @splice.span_edit(
                      start=value_start,
                      end=value_end,
                      @utf8.encode(if done { "1" } else { "0" }),
                    ),
                  )
                None =>
                  plan.edit_part(
                    part,
                    @splice.span_edit(
                      start=location.insert_at,
                      end=location.insert_at,
                      @utf8.encode(
                        " \{location.done_prefix}:done=\"\{if done { "1" } else { "0" }}\"",
                      ),
                    ),
                  )
              }
            }
          }
          if !flipped {
            // No entry yet: the flip becomes an addition.
            additions.push({
              para_id: canonical_para_id(key),
              done,
              parent_para_id: None,
            })
          }
        }
        None => ()
      }
      if additions.length() > 0 {
        let mut fragment = ""
        for entry in additions {
          fragment += comment_ex_fragment(entry)
        }
        match root_close {
          Some(insert_at) =>
            plan.edit_part(
              part,
              @splice.span_edit(
                start=insert_at,
                end=insert_at,
                @utf8.encode(fragment),
              ),
            )
          None => {
            let (root_start, root_end) = root_span
            plan.edit_part(
              part,
              self_closing_rewrite_range(bytes, root_start, root_end, fragment),
            )
          }
        }
      }
    }
    None => {
      // Create the part fresh (fully ours) and wire it.
      let all_entries : Array[CommentExEntry] = []
      match flip {
        Some((key, done)) =>
          all_entries.push({
            para_id: canonical_para_id(key),
            done,
            parent_para_id: None,
          })
        None => ()
      }
      for entry in additions {
        all_entries.push(entry)
      }
      let records : Array[XmlNode] = []
      for entry in all_entries {
        let attributes : Map[String, String] = {
          "w15:paraId": entry.para_id,
          "w15:done": if entry.done {
            "1"
          } else {
            "0"
          },
        }
        match entry.parent_para_id {
          Some(parent) => attributes["w15:paraIdParent"] = parent
          None => ()
        }
        records.push(XmlElement(@xml.xml_element("w15:commentEx", attributes~)))
      }
      let part_text = "" +
        @xml.write_xml_fragment(
          @xml.xml_element(
            "w15:commentsEx",
            attributes={ "mc:Ignorable": "w15" },
            children=records,
          ),
          namespaces={ "w15": W15_NAMESPACE, "mc": MC_NAMESPACE },
        )
      let part_path = main_sibling_mutation_part_path(
        annotated, zip, "commentsExtended.xml",
      )
      if zip.exists(part_path.logical) {
        raise Unsupported(
          message="'\{part_path.logical}' exists but is not wired as the commentsExtended part (an orphan); refusing",
        )
      }
      plan.add_part(part_path.physical, @utf8.encode(part_text))
      let rels_path = main_relationships_mutation_part_path(annotated, zip)
      let relationship_type = "http://schemas.microsoft.com/office/2011/relationships/commentsExtended"
      guard zip.resolve_path(rels_path.logical) is Some(actual_rels_part) else {
        raise Unsupported(
          message="the main part has no relationships part; refusing",
        )
      }
      guard zip.read_bytes(actual_rels_part) is Some(rels_bytes) else {
        raise Unsupported(
          message="the package index lost '\{actual_rels_part}'; refusing",
        )
      }
      let rels_root = read_mutation_relationships(
        actual_rels_part, rels_bytes, xml_budget,
      )
      let existing_ids : StableStringSet = SortedSet([])
      collect_relationship_ids(rels_root, existing_ids)
      let mut ordinal = 1
      while existing_ids.contains("rIdAnnotate\{ordinal}") {
        ordinal += 1
      }
      let rels_span = scan_xml_root_span(actual_rels_part, rels_bytes)
      let relationship_name = qualified_child_name(
        rels_span.name,
        "Relationship",
      )
      plan.edit_part(
        actual_rels_part,
        xml_root_child_edit(
          rels_bytes,
          rels_span,
          "<\{relationship_name} Id=\"rIdAnnotate\{ordinal}\" Type=\"\{relationship_type}\" Target=\"commentsExtended.xml\"/>",
        ),
      )
      guard zip.resolve_path("[Content_Types].xml") is Some(types_part) else {
        raise Unsupported(message="the package is missing [Content_Types].xml")
      }
      guard zip.read_bytes(types_part) is Some(types_bytes) else {
        raise Unsupported(message="the package index lost '\{types_part}'")
      }
      read_mutation_xml_root(
        types_part,
        types_bytes,
        xml_budget,
        expected_root="content-types:Types",
      )
      |> ignore
      let types_span = scan_xml_root_span(types_part, types_bytes)
      let override_name = qualified_child_name(types_span.name, "Override")
      plan.edit_part(
        types_part,
        xml_root_child_edit(
          types_bytes,
          types_span,
          opc_content_type_override_fragment(
            override_name,
            part_path.logical,
            "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml",
          ),
        ),
      )
    }
  }
}

///|
/// A self-closing element (raw byte range) rewritten to open form with
/// `inner` inside — the range variant of self_closing_rewrite.
fn self_closing_rewrite_range(
  part_bytes : BytesView,
  start : Int,
  end : Int,
  inner : String,
) -> @splice.SpanEdit raise DocxError {
  let slice = part_bytes[start:end]
  let mut name_end = 1
  while name_end < slice.length() &&
        slice[name_end] != b' ' &&
        slice[name_end] != b'/' &&
        slice[name_end] != b'>' &&
        slice[name_end] != b'\t' &&
        slice[name_end] != b'\r' &&
        slice[name_end] != b'\n' {
    name_end += 1
  }
  let replacement = Buffer()
  replacement.write_bytesview(slice[0:slice.length() - 2])
  replacement.write_bytes(b">")
  replacement.write_bytes(@utf8.encode(inner))
  replacement.write_bytes(b"")
  @splice.span_edit(start~, end~, replacement.contents())
}