// PR 3 of the reader unification (#434): the DOM <-> physical join.
//
// One story part is read three ways: the strict parser gates mutation
// safety, the byte scanner extracts physical identities and spans, and the
// tolerant parser builds the DOM the reader walks. The join pairs every DOM
// element with its physical `ScannedElement` identity by walking both trees
// in lockstep; any disagreement is a refusal, never a guess. PR 4 threads
// the paired identity into `ReaderNode`; until then the join is new
// machinery with no production caller.
//
// The one structural transform the join must model: the tolerant parser
// splices a CHILD `mc:AlternateContent` into its first `mc:Fallback`'s
// children at parse time, and it does so on the CANONICALIZED name -- any
// prefix bound to the MC namespace URI splices, not only the literal `mc:`
// spelling. The scanner keeps the full physical construct. The join
// therefore computes each physical parent's "cooked" child sequence with
// the same URI-based rule before comparing. A root `AlternateContent` is
// never spliced (it was not parsed as somebody's child) and pairs
// normally. An UNBOUND literal `mc:` prefix would also splice tolerantly
// while the scanner sees no MC namespace -- but the strict gate refuses
// unbound prefixes before the join ever runs.

///|
/// A physical element identity: the index into the story scan's element
/// table, assigned in opening-tag order.
// `Eq` is production-used (`reader_projection_adapter.mbt`); `Debug` only by
// whitebox snapshots, which the library build does not compile.
#warnings("-unused_value")
priv struct SourceElementId(Int) derive(Debug, Eq)

///|
/// A joined story: the reader-input tree whose every node carries its
/// physical identity, plus the scan those identities index. The join
/// builds the `ReaderNode` tree directly rather than a side table:
/// `XmlElement` has no stable identity to key on (synthetic
/// `first_or_empty` nodes and fresh wrapper construction make reference
/// identity an aliasing accident).
priv struct JoinedReaderStory {
  root : ReaderNode
  scan : StoryScan
}

///|
/// Which stage of the end-to-end preparation refused.
priv enum ReaderInputJoinStage {
  StrictGate
  SourceScan
  TolerantDom
}

///|
/// Why a story could not be joined. Stage refusals preserve the original
/// error; the three mismatch classes are the join's own findings and carry
/// bounded diagnostics (identities, names, counts -- never raw XML).
priv enum ReaderInputJoinRefusal {
  StageRefused(stage~ : ReaderInputJoinStage, cause~ : DocxError)
  ChildCountMismatch(
    parent~ : SourceElementId,
    dom_count~ : Int,
    cooked_source_count~ : Int
  )
  NameMismatch(
    source~ : SourceElementId,
    dom_name~ : String,
    source_uri~ : String,
    source_local_name~ : String
  )
  StructureMismatch(detail~ : String)
}

///|
priv enum ReaderInputJoinResult {
  Joined(JoinedReaderStory)
  Refused(ReaderInputJoinRefusal)
}

///|
/// A bounded human-readable form for tests and diagnostics.
fn ReaderInputJoinRefusal::describe(self : ReaderInputJoinRefusal) -> String {
  fn error_message(cause : DocxError) -> String {
    match cause {
      InvalidZip(message~) => "InvalidZip: " + message
      InvalidXml(message~) => "InvalidXml: " + message
      MissingPart(message~) => "MissingPart: " + message
      Unsupported(message~) => "Unsupported: " + message
      ResourceLimit(limit=_, message~) => "ResourceLimit: " + message
      WriteResourceLimit(kind=_, limit=_, actual=_, message~) =>
        "WriteResourceLimit: " + message
    }
  }

  match self {
    StageRefused(stage~, cause~) => {
      let stage_label = match stage {
        StrictGate => "strict-gate"
        SourceScan => "source-scan"
        TolerantDom => "tolerant-dom"
      }
      "refused at \{stage_label}: \{error_message(cause)}"
    }
    ChildCountMismatch(parent~, dom_count~, cooked_source_count~) => {
      let SourceElementId(parent_index) = parent
      "child count mismatch under source element \{parent_index}: dom has \{dom_count}, cooked source has \{cooked_source_count}"
    }
    NameMismatch(source~, dom_name~, source_uri~, source_local_name~) => {
      let SourceElementId(index) = source
      "name mismatch at source element \{index}: dom \{dom_name}, source {\{source_uri}}\{source_local_name}"
    }
    StructureMismatch(detail~) => "structure mismatch: " + detail
  }
}

///|
/// The DOM name the tolerant parser would produce for a physical element,
/// mirroring `XmlParser::map_uri_name` for elements: an empty URI keeps the
/// bare local name, a mapped URI becomes `prefix:local` (an empty canonical
/// prefix falls back to the braced form), and an unmapped URI becomes
/// `{uri}local`.
fn reader_join_dom_name_matches(
  namespace_map : Map[String, String],
  uri : String,
  local_name : String,
  dom_name : String,
) -> Bool {
  if uri == "" {
    // no namespace: an undeclared default keeps the bare name, while an
    // EXPLICIT xmlns="" routes through the tolerant mapper and comes out
    // braced-empty; the scanner records "" for both and the strict gate
    // accepts both
    dom_name == local_name || dom_name == "{}" + local_name
  } else if uri == XML_NAMESPACE_URI {
    // the strict gate and the scanner install the implicit `xml` binding;
    // tolerant parsing keeps the raw `xml:` name unless the source declares
    // it, in which case the unmapped URI comes out braced
    dom_name == "xml:" + local_name || dom_name == "{" + uri + "}" + local_name
  } else {
    match namespace_map.get(uri) {
      Some("") | None => dom_name == "{" + uri + "}" + local_name
      Some(prefix) => dom_name == prefix + ":" + local_name
    }
  }
}

///|
/// Validates the physical table's structural invariants so a later
/// mismatch means what it says. These hold for every scanner-built table;
/// checking keeps hand-built or corrupted inputs fail-closed.
fn validate_scanned_element_table(scan : StoryScan) -> String? {
  let elements = scan.elements()
  if elements.length() == 0 {
    return Some("empty physical element table")
  }
  for index in 0..= elements.length()
      ) {
      return Some(
        "element \{index} has out-of-range first child \{element.first_child_index}",
      )
    }
    if element.next_sibling_index != -1 &&
      (
        element.next_sibling_index < 0 ||
        element.next_sibling_index >= elements.length()
      ) {
      return Some(
        "element \{index} has out-of-range sibling \{element.next_sibling_index}",
      )
    }
  }
  if elements[0].parent_index != -1 {
    return Some("root element has parent \{elements[0].parent_index}")
  }
  if elements[0].next_sibling_index != -1 {
    return Some("root element has a sibling \{elements[0].next_sibling_index}")
  }
  // opening-tag preorder is the identity contract: an explicit DFS over the
  // links must visit element k as the k-th node, with parents agreeing.
  // (An iterative stack: synthetic tables are not bounded by the scanner's
  // depth cap.)
  let mut visited = 0
  let stack : Array[Int] = [0]
  while stack.length() > 0 {
    let index = stack.unsafe_pop()
    if index != visited {
      return Some(
        "element \{index} sits at preorder position \{visited}; the table is not in opening-tag order",
      )
    }
    visited = visited + 1
    if elements[index].next_sibling_index != -1 {
      if index == 0 {
        return Some("root element has a sibling")
      }
      if elements[elements[index].next_sibling_index].parent_index !=
        elements[index].parent_index {
        return Some(
          "siblings \{index} and \{elements[index].next_sibling_index} record different parents",
        )
      }
      stack.push(elements[index].next_sibling_index)
    }
    if elements[index].first_child_index != -1 {
      if elements[elements[index].first_child_index].parent_index != index {
        return Some(
          "element \{elements[index].first_child_index} is linked under \{index} but records parent \{elements[elements[index].first_child_index].parent_index}",
        )
      }
      stack.push(elements[index].first_child_index)
    }
  }
  if visited != elements.length() {
    return Some(
      "only \{visited} of \{elements.length()} elements are reachable from the root",
    )
  }
  None
}

///|
/// The child sequence the tolerant parser would cook from one physical
/// parent's direct children: an MC `AlternateContent` child is replaced by
/// its first MC `Fallback`'s cooked children (nothing when it has no
/// fallback); everything else contributes itself. The rule matches on the
/// namespace URI, exactly like the parser's canonicalize-then-splice.
fn cooked_scanned_children(scan : StoryScan, parent_index : Int) -> Array[Int] {
  let elements = scan.elements()
  let cooked : Array[Int] = []
  let mut child = elements[parent_index].first_child_index
  while child != -1 {
    let element = elements[child]
    if element.uri == MC_URI && element.local_name == "AlternateContent" {
      cooked.append(cooked_alternate_content_selection(scan, child))
    } else {
      cooked.push(child)
    }
    child = elements[child].next_sibling_index
  }
  cooked
}

///|
/// What one MC `AlternateContent` contributes: the parser parses (and
/// thereby cooks) the construct's children FIRST and only then searches
/// them for the first `mc:Fallback` -- so a fallback PROMOTED out of a
/// nested `AlternateContent` can be the one selected. The selection is the
/// chosen fallback's own cooked children, or nothing.
fn cooked_alternate_content_selection(
  scan : StoryScan,
  alternate_index : Int,
) -> Array[Int] {
  let elements = scan.elements()
  for candidate in cooked_scanned_children(scan, alternate_index) {
    let element = elements[candidate]
    if element.uri == MC_URI && element.local_name == "Fallback" {
      return cooked_scanned_children(scan, candidate)
    }
  }
  []
}

///|
/// Pairs a tolerant DOM with the physical element table. The root pairs
/// with identity 0 (a root `AlternateContent` is never spliced); below it,
/// each DOM element's children are zipped against the physical parent's
/// cooked child sequence, name-checked position by position, and recursed.
fn join_reader_dom(
  root : XmlElement,
  scan : StoryScan,
) -> ReaderInputJoinResult {
  match validate_scanned_element_table(scan) {
    Some(detail) => return Refused(StructureMismatch(detail~))
    None => ()
  }
  join_reader_element(root, 0, scan, office_namespace_map())
}

///|
fn join_reader_element(
  element : XmlElement,
  source_index : Int,
  scan : StoryScan,
  namespace_map : Map[String, String],
) -> ReaderInputJoinResult {
  let source = scan.elements()[source_index]
  if !reader_join_dom_name_matches(
      namespace_map,
      source.uri,
      source.local_name,
      element.name,
    ) {
    return Refused(
      NameMismatch(
        source=SourceElementId(source_index),
        dom_name=element.name,
        source_uri=source.uri,
        source_local_name=source.local_name,
      ),
    )
  }
  let dom_children : Array[XmlElement] = []
  for child in element.children {
    match child {
      XmlElement(child_element) => dom_children.push(child_element)
      XmlText(_) => ()
    }
  }
  let cooked = cooked_scanned_children(scan, source_index)
  if dom_children.length() != cooked.length() {
    return Refused(
      ChildCountMismatch(
        parent=SourceElementId(source_index),
        dom_count=dom_children.length(),
        cooked_source_count=cooked.length(),
      ),
    )
  }
  let joined_children : Array[ReaderNode] = []
  for index in 0.. joined_children.push(story.root)
      Refused(_) as refused => return refused
    }
  }
  Joined({
    root: {
      element,
      source_element_id: Some(SourceElementId(source_index)),
      children: joined_children,
    },
    scan,
  })
}

///|
/// The end-to-end preparation for one story part: strict gate, physical
/// scan, tolerant parse, join. Each stage's refusal is preserved; there is
/// deliberately NO fallback to an unjoined read -- a caller that wants the
/// plain tolerant DOM simply does not call this.
#warnings("-unused_value")
fn join_reader_input_story(bytes : BytesView) -> ReaderInputJoinResult {
  join_reader_input_story_limited(bytes)
}

///|
/// The budget-threaded form for production callers: the strict gate and
/// the tolerant DOM keep SEPARATE budgets because their callers account
/// differently -- main-document strict validation runs against a local
/// transient budget while its tolerant DOM charges the caller's
/// cumulative one. Omitted budgets fall back to the part-local linear
/// allowance, exactly as the unbudgeted wrapper always has.
fn join_reader_input_story_limited(
  bytes : BytesView,
  strict_budget? : @xml.XmlReadBudget,
  tolerant_budget? : @xml.XmlReadBudget,
  path_budget? : AnnotationPathBudget,
) -> ReaderInputJoinResult {
  let _ = read_identity_story_xml_strict(bytes, strict_budget) catch {
    cause => return Refused(StageRefused(stage=StrictGate, cause~))
  }
  let scan = scan_projection_source_tree(bytes, path_budget?) catch {
    cause => return Refused(StageRefused(stage=SourceScan, cause~))
  }
  let tolerant = match tolerant_budget {
    Some(value) => value
    None => local_input_linear_xml_budget(bytes)
  }
  let root = @xml.read_xml_bytes_limited(
    bytes,
    tolerant,
    namespace_map=office_namespace_map(),
  ) catch {
    cause => return Refused(StageRefused(stage=TolerantDom, cause~))
  }
  join_reader_dom(root, scan)
}