// The literal find-and-replace planner (N2b). One match model: this
// consumes `find_docx_matches`' own enumeration, so what replace acts on
// is EXACTLY what find reported — same ordinals, same verdicts. A
// separate matcher here would eventually disagree with find, and an
// agent that previews with find must never be surprised by replace.

///|
/// What a planned replacement will do, and what the document must read
/// afterwards.
pub struct DocxReplaceReceipt {
  selected : Array[Int]
  replaced : Int
  affected : Array[DocxReplaceAffected]
  matches : Array[DocxMatch]
}

///|
/// One affected paragraph: its path, and the full projection text it
/// must carry after the splice.
///
/// The expectation is the WHOLE paragraph projection, precomputed at
/// plan time — never a substring probe, because the replacement text may
/// pre-exist elsewhere in the same paragraph and a substring check would
/// pass on a splice that landed in the wrong place.
pub struct DocxReplaceAffected {
  path : String
  expected : String
  // The paragraph's anchor judgment, copied from its selected matches
  // (same paragraph, same judgment). A planned paragraph is never
  // `multi_physical` — the planner refuses those — but it can carry a
  // duplicate or missing anchor and still be ordinal-editable.
  para_id : String?
  anchor_status : String
}

///|
/// The selected candidates themselves — the same entries `find` would
/// report, so a dry-run can print the matches payload the roadmap
/// promises rather than a summary that hides ranges and runs.
pub fn DocxReplaceReceipt::matches(
  self : DocxReplaceReceipt,
) -> Array[DocxMatch] {
  self.matches
}

///|
/// The candidate ordinals this plan replaces, in document order.
pub fn DocxReplaceReceipt::selected(self : DocxReplaceReceipt) -> Array[Int] {
  self.selected
}

///|
/// How many replacements the plan performs.
pub fn DocxReplaceReceipt::replaced(self : DocxReplaceReceipt) -> Int {
  self.replaced
}

///|
/// The affected paragraphs with their expected post-edit projections.
pub fn DocxReplaceReceipt::affected(
  self : DocxReplaceReceipt,
) -> Array[DocxReplaceAffected] {
  self.affected
}

///|
/// The paragraph's body-relative path.
pub fn DocxReplaceAffected::path(self : DocxReplaceAffected) -> String {
  self.path
}

///|
/// The paragraph's canonical anchor paraId, when the source carries one
/// (the anchor judgment copied from its selected matches).
pub fn DocxReplaceAffected::para_id(self : DocxReplaceAffected) -> String? {
  self.para_id
}

///|
/// The paragraph's anchor status (`unique`, `missing`, `invalid`,
/// `duplicate`), copied from its selected matches. Never
/// `multi_physical`: the planner refuses those.
pub fn DocxReplaceAffected::anchor_status(self : DocxReplaceAffected) -> String {
  self.anchor_status
}

///|
/// The full projection text the paragraph must read after the edit.
pub fn DocxReplaceAffected::expected(self : DocxReplaceAffected) -> String {
  self.expected
}

///|
/// Plan replacing `needle` with `replacement` in the body story.
///
/// The locked selection semantics, verbatim from the roadmap:
///
/// - Ordinals run over ALL candidates in document order, including
///   restricted ones — the same ordinals `find` reports.
/// - `nth` selects ONE candidate by that ordinal, BEFORE actionability
///   is considered; selecting a restricted candidate is a refusal that
///   names its reason, not a skip.
/// - Without `nth`, every candidate is selected, and ANY restricted
///   candidate in scope refuses — replace never silently skips what
///   find reports.
///
/// Zero candidates plans nothing and returns an empty receipt; whether
/// that is an error belongs to the caller (`--allow-zero` is CLI
/// policy, not engine fact).
///
/// The returned plan is pinned to the annotated read's retained bytes,
/// so applying it to any other snapshot refuses as stale.
pub fn plan_docx_replacements(
  annotated : DocxAnnotatedResult,
  story : DocxStoryPartSource,
  needle~ : String,
  replacement~ : String,
  within? : String,
  nth? : Int,
) -> (@splice.SplicePlan, DocxReplaceReceipt) raise DocxError {
  let part = story.part()
  // The SAME enumeration find shows the caller. The examination ceiling
  // is find's hard limit; a document with more candidates in scope is
  // refused with advice rather than acted on partially, because "I
  // replaced some of them" is exactly the silent-skip this surface
  // promises never to do.
  let found = find_docx_matches(annotated, story, needle~, within?, limit=1000)
  if found.truncated() {
    raise Unsupported(
      message="\{found.total()} candidates for the needle exceed the 1000-candidate ceiling; narrow the scope with a subtree restriction or a longer needle",
    )
  }
  let matches = found.matches()
  // Selection.
  let selected : Array[DocxMatch] = []
  match nth {
    Some(ordinal) => {
      guard ordinal >= 1 && ordinal <= matches.length() else {
        raise Unsupported(
          message="nth selects candidate \{ordinal}, but the document has \{matches.length()} candidate(s)",
        )
      }
      selected.push(matches[ordinal - 1])
    }
    None =>
      for hit in matches {
        selected.push(hit)
      }
  }
  // Actionability, AFTER selection: a restricted candidate refuses by
  // ordinal and reason. The first offender is named; the count says
  // whether narrowing to `nth` could help.
  let mut restricted = 0
  let mut first : DocxMatch? = None
  for hit in selected {
    if !hit.actionable() {
      restricted = restricted + 1
      if first is None {
        first = Some(hit)
      }
    }
  }
  if first is Some(offender) {
    raise Unsupported(
      message="candidate \{offender.ordinal()} at \{offender.path()} is not editable (\{offender.reason()}); \{restricted} of \{selected.length()} selected candidate(s) are restricted",
    )
  }
  guard annotated.reader_projections.get(part) is Some(projection) else {
    raise Unsupported(
      message="replace requires a mutation-safe read with a retained projection for '\{part}'",
    )
  }
  guard annotated.reader_projection_sources.get(part) is Some(bytes) else {
    raise Unsupported(
      message="the mutation-safe read retained no source bytes for '\{part}'",
    )
  }
  // Resolve each match's paragraph index through the scan paths — the
  // same identity find used to name it.
  let paragraph_paths = find_path_map(projection.scan, "p")
  let elements = projection.scan.elements()
  let index_by_path : Map[String, Int] = Map([])
  for paragraph_index, paragraph in projection.paragraphs {
    guard paragraph.sources.length() > 0 else { continue }
    let SourceElementId(head) = paragraph.sources[0].source
    if paragraph_paths.get(elements[head].byte_start) is Some(path) {
      index_by_path[path] = paragraph_index
    }
  }
  // One batch per paragraph, in document order: the partial planner
  // validates the batch as a whole (ordering, overlap, every structural
  // gate), which is the point of batching rather than planning one edit
  // at a time against a moving document.
  let plan = @splice.SplicePlan::new()
  plan.pin_part(part, bytes)
  let affected : Array[DocxReplaceAffected] = []
  let ordinals : Array[Int] = []
  let mut replaced = 0
  let mut cursor = 0
  while cursor < selected.length() {
    let path = selected[cursor].path()
    let mut stop = cursor
    while stop < selected.length() && selected[stop].path() == path {
      stop = stop + 1
    }
    guard index_by_path.get(path) is Some(paragraph_index) else {
      raise Unsupported(
        message="candidate paragraph '\{path}' did not resolve to a projection paragraph",
      )
    }
    let edits : Array[ParagraphTextEdit] = []
    for at in cursor.. String? {
  guard annotated.reader_projections.get(story.part()) is Some(projection) else {
    return None
  }
  let paragraph_paths = find_path_map(projection.scan, "p")
  let elements = projection.scan.elements()
  for paragraph in projection.paragraphs {
    guard paragraph.sources.length() > 0 else { continue }
    let SourceElementId(head) = paragraph.sources[0].source
    if paragraph_paths.get(elements[head].byte_start) == Some(path) {
      return Some(find_paragraph_projection(paragraph))
    }
  }
  None
}