// L0 span queries: the byte-level insertion offsets the preservation
// (splice) layer needs, resolved from the J1 scanner's projection of
// the ORIGINAL part bytes. Offsets index into the story part named by
// `main_story_part` — never into the whole zip.

///|
/// The byte extent of one BODY-story projection node (paragraph or
/// run) in the original word-processing part, plus the two insertion
/// offsets annotation surgery uses: `content_start` (just past the
/// open tag AND past a leading pPr/rPr — the property container leads
/// its sequence, so content and markers must land after it) and
/// `close_tag_start` (the '<' of the close tag). Both are None for a
/// self-closing form, which has no interior — the splice layer must
/// first rewrite it to the open form (a byte edit of this node's own
/// span).
pub struct NodeSpan {
  priv byte_start : Int
  priv byte_end : Int
  priv content_start : Int
  priv close_tag_start : Int
}

///|
/// Offset of the '<' of the paragraph's open tag.
pub fn NodeSpan::byte_start(self : NodeSpan) -> Int {
  self.byte_start
}

///|
/// Offset just past the '>' of the paragraph's close tag (exclusive).
pub fn NodeSpan::byte_end(self : NodeSpan) -> Int {
  self.byte_end
}

///|
/// Earliest interior insertion offset (past the open tag and any
/// leading pPr), or None for a self-closing paragraph.
pub fn NodeSpan::content_start(self : NodeSpan) -> Int? {
  if self.content_start < 0 {
    None
  } else {
    Some(self.content_start)
  }
}

///|
/// Offset of the '<' of the close tag, or None for a self-closing
/// paragraph.
pub fn NodeSpan::close_tag_start(self : NodeSpan) -> Int? {
  if self.close_tag_start < 0 {
    None
  } else {
    Some(self.close_tag_start)
  }
}

///|
/// True when the paragraph is the self-closing `` form.
pub fn NodeSpan::self_closing(self : NodeSpan) -> Bool {
  self.content_start < 0
}

///|
/// One element-name rewrite: the byte range of an element's LOCAL NAME in
/// the original part, and the name it becomes. Rejecting a deletion turns
/// every `w:delText` back into `w:t` through these ranges, which leaves the
/// namespace prefix, `xml:space`, and every other byte of the tag untouched.
pub struct RevisionNameEdit {
  priv start : Int
  priv end : Int
  priv replacement : String
}

///|
/// Offset of the first byte of the local name.
pub fn RevisionNameEdit::start(self : RevisionNameEdit) -> Int {
  self.start
}

///|
/// Offset just past the last byte of the local name (exclusive).
pub fn RevisionNameEdit::end(self : RevisionNameEdit) -> Int {
  self.end
}

///|
/// The local name that replaces the range ("t" or "instrText").
pub fn RevisionNameEdit::replacement(self : RevisionNameEdit) -> String {
  self.replacement
}

///|
/// One tracked-change ELEMENT in the original bytes: what it is, who
/// recorded it, and the byte extent a resolver needs to unwrap or remove it.
///
/// This is the MUTATION view of a revision and is deliberately wider than
/// `AnnotationIndex::revisions`, which is the READING view. Reading reports
/// the content insertions and deletions the reader's projection retains;
/// resolving must additionally see every construct it cannot act on, because
/// a construct it cannot see is a construct it would silently leave behind.
/// `supported` separates the two: it is true only for content `w:ins` and
/// `w:del`, false for property revisions, moves, and every `*PrChange`.
pub struct RevisionSpan {
  priv story : String
  priv kind : String
  priv supported : Bool
  priv id : String?
  priv author : String?
  priv date : String?
  priv container_path : String?
  priv byte_start : Int
  priv byte_end : Int
  priv content_start : Int
  priv close_tag_start : Int
  priv declares_namespaces : Bool
  priv name_edits : Array[RevisionNameEdit]
}

///|
/// The story key the span's offsets index into ("/body", "/header[1]").
pub fn RevisionSpan::story(self : RevisionSpan) -> String {
  self.story
}

///|
/// The WML local name of the element ("ins", "del", "moveFrom", "rPrChange").
pub fn RevisionSpan::kind(self : RevisionSpan) -> String {
  self.kind
}

///|
/// True only for a content `w:ins`/`w:del` a resolver can accept or reject.
pub fn RevisionSpan::supported(self : RevisionSpan) -> Bool {
  self.supported
}

///|
/// The w:id attribute, as spelled, when present.
pub fn RevisionSpan::id(self : RevisionSpan) -> String? {
  self.id
}

///|
/// The w:author attribute, as spelled, when present.
pub fn RevisionSpan::author(self : RevisionSpan) -> String? {
  self.author
}

///|
/// The w:date attribute, LEXICAL (never converted), when present.
pub fn RevisionSpan::date(self : RevisionSpan) -> String? {
  self.date
}

///|
/// The story-relative projection path of the innermost containing node
/// ("p[2]", "tbl[1]/tr[1]/tc[1]/p[1]"), or None at story level.
pub fn RevisionSpan::container_path(self : RevisionSpan) -> String? {
  self.container_path
}

///|
/// Offset of the '<' of the element's open tag.
pub fn RevisionSpan::byte_start(self : RevisionSpan) -> Int {
  self.byte_start
}

///|
/// Offset just past the '>' that ends the element (exclusive).
pub fn RevisionSpan::byte_end(self : RevisionSpan) -> Int {
  self.byte_end
}

///|
/// Offset just past the '>' of the open tag, or None for the self-closing
/// form — which has no content to keep, so all four resolutions of it
/// collapse to removing the element.
pub fn RevisionSpan::content_start(self : RevisionSpan) -> Int? {
  if self.content_start < 0 {
    None
  } else {
    Some(self.content_start)
  }
}

///|
/// Offset of the '<' of the close tag, or None for the self-closing form.
pub fn RevisionSpan::close_tag_start(self : RevisionSpan) -> Int? {
  if self.close_tag_start < 0 {
    None
  } else {
    Some(self.close_tag_start)
  }
}

///|
/// True when the element's own start tag binds a namespace prefix. Unwrapping
/// it would drop the binding while its former children still use it, so an
/// unwrap resolution must refuse; removing the whole element stays safe.
pub fn RevisionSpan::declares_namespaces(self : RevisionSpan) -> Bool {
  self.declares_namespaces
}

///|
/// The `w:delText`/`w:delInstrText` names inside this deletion, in document
/// order; empty for an insertion. Rejecting the deletion applies all of them.
pub fn RevisionSpan::name_edits(self : RevisionSpan) -> Array[RevisionNameEdit] {
  self.name_edits.copy()
}

///|
/// Every tracked-change element across every scanned story, in story order
/// then document order. Offsets index into the part that backs each story.
///
/// The list is complete BY CONSTRUCTION: the scanner records a site for every
/// tracked-change element it walks, without the suppression and projection
/// gates the reading index applies, and retraction never removes one. A
/// resolver can therefore treat an empty result as proof that the package
/// carries no tracked changes at all.
pub fn DocxAnnotatedResult::revision_spans(
  self : DocxAnnotatedResult,
) -> Array[RevisionSpan] {
  let spans : Array[RevisionSpan] = []
  for entry in self.index.scans {
    let (story, scan) = entry
    for site in scan.revision_sites() {
      let name_edits : Array[RevisionNameEdit] = []
      for record in site.name_edits {
        name_edits.push({
          start: record.open_start,
          end: record.open_end,
          replacement: record.replacement,
        })
        if record.close_start >= 0 {
          name_edits.push({
            start: record.close_start,
            end: record.close_end,
            replacement: record.replacement,
          })
        }
      }
      spans.push({
        story,
        kind: site.local_name,
        supported: site.supported,
        id: site.id,
        author: site.author,
        date: site.date,
        container_path: site.container_path,
        byte_start: site.byte_start,
        byte_end: site.byte_end,
        content_start: site.content_start,
        close_tag_start: site.close_tag_start,
        declares_namespaces: site.declares_namespaces,
        name_edits,
      })
    }
  }
  spans
}

///|
/// The zip entry name of the MAIN document part the body-story spans
/// index into (resolved through the officeDocument relationship, never
/// hardcoded).
pub fn DocxAnnotatedResult::main_story_part(
  self : DocxAnnotatedResult,
) -> String {
  self.main_part
}

///|
/// The span of the body-story paragraph at `relative_path` — the
/// story-relative ORDINAL path exactly as the index emits it
/// ("p[3]", "tbl[1]/tr[2]/tc[1]/p[1]") — or None when no such
/// paragraph was projected. Offsets index into `main_story_part`'s
/// original bytes.
pub fn DocxAnnotatedResult::body_paragraph_span(
  self : DocxAnnotatedResult,
  relative_path : String,
) -> NodeSpan? {
  body_node_span_of_kind(self, relative_path, "p")
}

///|
/// The span of the body-story RUN at `relative_path` ("p[3]/r[2]", the
/// index's ordinal form), or None. Runs carry the same offsets as
/// paragraphs (content_start past a leading rPr) so L1 can rewrite a
/// self-closing `` by its own extent too.
pub fn DocxAnnotatedResult::body_run_span(
  self : DocxAnnotatedResult,
  relative_path : String,
) -> NodeSpan? {
  body_node_span_of_kind(self, relative_path, "r")
}

///|
/// The ROOT element's span for a scanned story ("/body", "/comments",
/// "/footnotes", "/endnotes"), or None when the story was not scanned.
/// L1 splices a new comment definition just before the comments
/// story's `close_tag_start`; a self-closing root reports no interior
/// and must be rewritten by its own extent first.
pub fn DocxAnnotatedResult::story_root_span(
  self : DocxAnnotatedResult,
  story : String,
) -> NodeSpan? {
  for entry in self.index.scans {
    let (key, scan) = entry
    if key != story {
      continue
    }
    match scan.root() {
      Some(node) =>
        return Some({
          byte_start: node.byte_start,
          byte_end: node.byte_end,
          content_start: node.content_start,
          close_tag_start: node.close_tag_start,
        })
      None => return None
    }
  }
  None
}

///|
/// The zip entry holding the comments part (resolved by RELATIONSHIP
/// TYPE from the main part, never by filename), or None when the
/// package has no comments part yet — L1 then creates it.
pub fn DocxAnnotatedResult::comments_part(
  self : DocxAnnotatedResult,
) -> String? {
  self.comments_part_name
}

///|
/// The zip entry holding the commentsExtended part (resolved by
/// RELATIONSHIP TYPE), or None when the package has none — L2 then
/// creates it.
pub fn DocxAnnotatedResult::comments_extended_part(
  self : DocxAnnotatedResult,
) -> String? {
  self.comments_extended_part_name
}

///|
/// The span of a paragraph in ANY scanned story ("/comments" included),
/// by its story-relative ordinal path ("comment[2]/p[1]") — offsets
/// index into that story's part bytes. L2's paraId retrofit edits the
/// parent comment's LAST body paragraph through this.
pub fn DocxAnnotatedResult::story_paragraph_span(
  self : DocxAnnotatedResult,
  story : String,
  relative_path : String,
) -> NodeSpan? {
  for entry in self.index.scans {
    let (key, scan) = entry
    if key != story {
      continue
    }
    for node in scan.nodes() {
      if node.kind == "p" && node.path == relative_path {
        return Some({
          byte_start: node.byte_start,
          byte_end: node.byte_end,
          content_start: node.content_start,
          close_tag_start: node.close_tag_start,
        })
      }
    }
  }
  None
}

///|
/// The span of one text RUN in any scanned story, addressed by the
/// story key ("/body", "/header[1]", ...) and the story-relative ordinal
/// path ("p[3]/r[2]", "p[1]/hyperlink[1]/r[1]"). The scanner already
/// records run nodes with full spans in every story; this generalizes
/// the body-only lookup so template merging can rewrite header and
/// footer runs through the same byte-span contract.
pub fn DocxAnnotatedResult::story_run_span(
  self : DocxAnnotatedResult,
  story : String,
  relative_path : String,
) -> NodeSpan? {
  for entry in self.index.scans {
    let (key, scan) = entry
    if key != story {
      continue
    }
    for node in scan.nodes() {
      if node.kind == "r" && node.path == relative_path {
        return Some({
          byte_start: node.byte_start,
          byte_end: node.byte_end,
          content_start: node.content_start,
          close_tag_start: node.close_tag_start,
        })
      }
    }
  }
  None
}

///|
/// The span of one table ROW in any scanned story, addressed by the
/// story key ("/body", "/header[1]", ...) and the story-relative ordinal
/// path ("tbl[1]/tr[2]"). The scanner already records tr nodes with full
/// spans; template repetition replaces a row's byte region with filled
/// clones through this.
pub fn DocxAnnotatedResult::story_row_span(
  self : DocxAnnotatedResult,
  story : String,
  relative_path : String,
) -> NodeSpan? {
  for entry in self.index.scans {
    let (key, scan) = entry
    if key != story {
      continue
    }
    for node in scan.nodes() {
      if node.kind == "tr" && node.path == relative_path {
        return Some({
          byte_start: node.byte_start,
          byte_end: node.byte_end,
          content_start: node.content_start,
          close_tag_start: node.close_tag_start,
        })
      }
    }
  }
  None
}

///|
fn body_node_span_of_kind(
  annotated : DocxAnnotatedResult,
  relative_path : String,
  kind : String,
) -> NodeSpan? {
  for entry in annotated.index.scans {
    let (story, scan) = entry
    if story != "/body" {
      continue
    }
    for node in scan.nodes() {
      if node.kind == kind && node.path == relative_path {
        return Some({
          byte_start: node.byte_start,
          byte_end: node.byte_end,
          content_start: node.content_start,
          close_tag_start: node.close_tag_start,
        })
      }
    }
  }
  None
}

// L0 span pins: the scanner's insertion offsets, checked against
// positions COMPUTED from the source text (find), never hard-coded.

///|
fn span_source_offset(source : String, needle : String) -> Int raise {
  match source.find(needle) {
    Some(offset) => offset
    None => fail("needle not in source: \{needle}")
  }
}

///|
test "L0: paragraph spans carry content_start past a leading pPr" {
  let source =
    #|xy
  let scan = scan_story(@utf8.encode(source))
  let mut p1_content = -2
  let mut p1_close = -2
  let mut p2_content = -2
  let mut p3_content = -2
  let mut p3_close = -2
  for node in scan.nodes() {
    if node.kind == "p" && node.path == "p[1]" {
      p1_content = node.content_start
      p1_close = node.close_tag_start
    }
    if node.kind == "p" && node.path == "p[2]" {
      p2_content = node.content_start
    }
    if node.kind == "p" && node.path == "p[3]" {
      p3_content = node.content_start
      p3_close = node.close_tag_start
    }
  }
  // p[1]: content starts right past , at the first run.
  assert_eq(p1_content, span_source_offset(source, "x"))
  assert_eq(p1_close, span_source_offset(source, "y"))
  // p[2] has no pPr: content starts right past the open tag.
  assert_eq(p2_content, span_source_offset(source, "y"))
  // p[3] is self-closing: no interior.
  assert_eq(p3_content, -1)
  assert_eq(p3_close, -1)
}

///|
test "L0: self-closing pPr and alternate prefixes still pin content_start" {
  // A self-closing  under an ALTERNATE WML prefix: URI-aware
  // matching must move content_start past it all the same.
  let source =
    #|z
  let scan = scan_story(@utf8.encode(source))
  let mut content = -2
  let mut close = -2
  for node in scan.nodes() {
    if node.kind == "p" && node.path == "p[1]" {
      content = node.content_start
      close = node.close_tag_start
    }
  }
  assert_eq(content, span_source_offset(source, "z"))
  assert_eq(close, span_source_offset(source, ""))
}

///|
test "L0: a nested pPr does not move an OUTER paragraph's content_start" {
  // The pPr belongs to the INNER paragraph of a table nested mid-way;
  // the outer paragraph's content_start must stay at its own first
  // child. (Direct-parent check, not nearest-ancestor.)
  let source =
    #|cellafter
  let scan = scan_story(@utf8.encode(source))
  let mut cell_content = -2
  let mut after_content = -2
  for node in scan.nodes() {
    if node.kind == "p" && node.path == "tbl[1]/tr[1]/tc[1]/p[1]" {
      cell_content = node.content_start
    }
    if node.kind == "p" && node.path == "p[1]" {
      after_content = node.content_start
    }
  }
  assert_eq(cell_content, span_source_offset(source, "cell"))
  assert_eq(after_content, span_source_offset(source, "after"))
}

///|
test "L0 review round 1: run spans mirror paragraph spans (rPr, self-closing)" {
  let source =
    #|bold
  let scan = scan_story(@utf8.encode(source))
  let mut r1_content = -2
  let mut r1_close = -2
  let mut r2_content = -2
  for node in scan.nodes() {
    if node.kind == "r" && node.path == "p[1]/r[1]" {
      r1_content = node.content_start
      r1_close = node.close_tag_start
    }
    if node.kind == "r" && node.path == "p[1]/r[2]" {
      r2_content = node.content_start
    }
  }
  // Content starts past the leading rPr (CT_R: rPr first).
  assert_eq(r1_content, span_source_offset(source, "bold"))
  assert_eq(r1_close, span_source_offset(source, ""))
  // The self-closing  has no interior.
  assert_eq(r2_content, -1)
}

///|
/// Site extents are checked against offsets COMPUTED from the source text, so
/// the assertions stay honest if the fixture is ever reformatted.
test "M0: revision sites carry the element's exact byte extent" {
  let source =
    #|agonenew
  let scan = scan_story(@utf8.encode(source))
  let sites = scan.revision_sites()
  inspect(sites.length(), content="3")
  let deletion = sites[0]
  inspect(deletion.local_name, content="del")
  inspect(deletion.supported, content="true")
  inspect(deletion.container_path.unwrap_or(""), content="p[1]")
  assert_eq(deletion.byte_start, span_source_offset(source, ""))
  assert_eq(deletion.byte_end, span_source_offset(source, ""),
  )
  // The self-closing form reports no interior at all.
  let empty = sites[2]
  inspect(empty.content_start, content="-1")
  inspect(empty.close_tag_start, content="-1")
  assert_eq(empty.byte_start, span_source_offset(source, ""))
  assert_eq(empty.byte_end, span_source_offset(source, ""))
}

///|
/// Every tracked-change construct is RECORDED, including the ones no resolver
/// in this build can act on. A construct the mutation layer cannot see is a
/// construct it would silently leave behind, so `supported` — not omission —
/// is what separates the resolvable set from the rest.
test "M0: unresolvable tracked-change constructs are recorded, not skipped" {
  let source =
    #|amn
  let scan = scan_story(@utf8.encode(source))
  let shape : Array[String] = []
  for site in scan.revision_sites() {
    shape.push("\{site.local_name}:\{site.supported}")
  }
  assert_eq(shape, [
    "ins:false", "rPrChange:false", "moveFrom:false", "ins:true",
  ])
  // The READING view is unchanged by any of this: it still reports only the
  // content revisions the reader's projection retains.
  let read : Array[String] = []
  for revision in scan.revisions() {
    read.push("\{revision.kind()}:\{revision.id().unwrap_or("")}")
  }
  assert_eq(read, ["ins:4"])
}

///|
/// The two views diverge on purpose where the reader RETRACTS a subtree. A
/// vertically merged continuation cell does not exist in the projection, so
/// its revisions leave the reading index — but their bytes are still in the
/// part, so their sites must survive.
test "M0: retracting a subtree drops read revisions but keeps their sites" {
  let source =
    #|ahidden
  let scan = scan_story(@utf8.encode(source))
  inspect(scan.revisions().length(), content="0")
  inspect(scan.revision_sites().length(), content="1")
  inspect(scan.revision_sites()[0].id.unwrap_or(""), content="7")
}

///|
/// A deleted table ROW is a property revision the reader removes wholesale.
/// The site still records it, so a resolver asked to accept everything
/// refuses instead of publishing a document that still holds a pending row
/// deletion.
test "M0: a deleted table row is recorded as an unresolvable site" {
  let source =
    #|a
  let scan = scan_story(@utf8.encode(source))
  inspect(scan.revisions().length(), content="0")
  let sites = scan.revision_sites()
  inspect(sites.length(), content="1")
  inspect(sites[0].local_name, content="del")
  inspect(sites[0].supported, content="false")
}

///|
/// Prefix spelling never enters the decision: a WML-bound prefix that is not
/// `w` is still a revision, and a `w`-prefixed element bound to some other
/// namespace is not.
test "M0: revision sites are matched by namespace, never by prefix" {
  let source =
    #|a
  let scan = scan_story(@utf8.encode(source))
  let sites = scan.revision_sites()
  inspect(sites.length(), content="1")
  inspect(sites[0].id.unwrap_or(""), content="1")
  inspect(sites[0].declares_namespaces, content="false")
}

///|
test "L1: story roots project with insertion offsets" {
  let source =
    #|x
  let scan = scan_story(@utf8.encode(source))
  guard scan.root() is Some(root) else { fail("expected root") }
  inspect(root.kind, content="comments")
  assert_eq(root.close_tag_start, span_source_offset(source, ""))
  assert_eq(root.content_start, span_source_offset(source, "",
    ),
  )
  guard empty.root() is Some(empty_root) else { fail("expected empty_root") }
  inspect(empty_root.content_start, content="-1")
}