///|
/// An opaque, validated drawing anchor retained because the semantic XLSX API
/// does not model its direct object payload yet.
priv struct PreservedDrawingAnchor {
  order : Int
  xml : String
}

///|
/// A relationship needed by an opaque drawing anchor or one of its retained
/// package parts. `target_part` is the normalized source-package path for an
/// internal target and absent for an external target.
priv struct PreservedDrawingRelationship {
  id : String
  rel_type : String
  target : String
  external : Bool
  target_part : String?
}

///|
/// One internal OPC part in the transitive relationship closure of an opaque
/// drawing anchor.
priv struct PreservedDrawingPart {
  source_path : String
  data : Bytes
  content_type : String
  relationships : Array[PreservedDrawingRelationship]
}

///|
/// An isolated output part used by an opaque drawing anchor. Rehoming retained
/// parts under a writer-owned directory prevents hostile or stale source paths
/// from replacing generated workbook parts.
priv struct PreservedDrawingWritePart {
  path : String
  data : Bytes
  content_type : String
  relationships : Array[PreservedDrawingRelationship]
}

///|
/// The complete write-time projection for one worksheet drawing.
priv struct PreservedDrawingWritePlan {
  anchors : Array[PreservedDrawingAnchor]
  drawing_relationships : Array[PreservedDrawingRelationship]
  parts : Array[PreservedDrawingWritePart]
  relationship_ids : Array[String]
  object_ids : Array[String]
}

///|
fn Worksheet::allocate_drawing_order(self : Worksheet) -> Int raise XlsxError {
  if self.next_drawing_order == 0x7fff_ffff {
    raise InvalidSheetOperation(msg="drawing order exhausted")
  }
  let order = self.next_drawing_order
  self.next_drawing_order = order + 1
  order
}

///|
fn preserved_drawing_relationships_from_xml(
  xml : StringView,
  source_part : StringView,
  part_names : Map[String, String],
  budget? : ReadBudget,
  cancelled? : () -> Bool = () => false,
) -> Array[PreservedDrawingRelationship] raise XlsxError {
  match budget {
    Some(value) => {
      value.checkpoint()
      value.charge_work(xml.length())
    }
    None => ()
  }
  let parsed = @ooxml.parse_package_relationships(xml, cancelled~) catch {
    InvalidXml(msg~) => raise InvalidXml(msg~)
    ReadCancelled => raise ReadCancelled
  }
  match budget {
    Some(value) => value.charge_items(parsed.length())
    None => ()
  }
  let relationships : Array[PreservedDrawingRelationship] = []
  for record in parsed {
    let target_part = if record.external {
      None
    } else {
      Some(
        logical_archive_part_path(
          actual_relationship_target_path(
            source_part,
            record.target,
            part_names,
            cancelled~,
          ),
        ),
      )
    }
    relationships.push({
      id: record.id,
      rel_type: record.rel_type,
      target: record.target,
      external: record.external,
      target_part,
    })
  }
  relationships
}

///|
fn opaque_drawing_relationship_ids(
  anchor_xml : StringView,
  cancelled? : () -> Bool = () => false,
) -> Array[String] raise XlsxError {
  // Canonical drawing anchors retain only supported core element namespaces;
  // supply their root bindings so every relationship attribute can be
  // enumerated by expanded name even though the retained substring no longer
  // contains the original wsDr start tag.
  let wrapped = "" +
    anchor_xml.to_owned() +
    ""
  let scanner = @ooxml.XmlStartTagScanner::new(wrapped, cancelled~)
  let ids : Array[String] = []
  let seen : Set[String] = Set([])
  while workbook_scanner_next(scanner) {
    for
      namespace_uri in [
        transitional_relationship_attribute_namespace, strict_relationship_attribute_namespace,
      ] {
      let values = scanner.attribute_values_in_namespace(namespace_uri) catch {
        InvalidXml(msg~) => raise InvalidXml(msg~)
        ReadCancelled => raise ReadCancelled
      }
      for value in values {
        if !seen.contains(value) {
          seen.add(value)
          ids.push(value)
        }
      }
    }
  }
  ids
}

///|
fn normalized_drawing_object_id(value : StringView) -> String {
  if value == "" {
    return ""
  }
  let mut first_nonzero = 0
  let mut index = 0
  while index < value.length() {
    let unit = value[index]
    if unit < ('0' : UInt16) || unit > ('9' : UInt16) {
      return value.to_owned()
    }
    if unit == ('0' : UInt16) && first_nonzero == index {
      first_nonzero = first_nonzero + 1
    }
    index = index + 1
  }
  if first_nonzero == value.length() {
    "0"
  } else {
    value[first_nonzero:].to_owned()
  }
}

///|
/// Reserve every numeric drawing object identifier used inside an opaque
/// anchor. Leading zeroes are normalized because `ST_DrawingElementId` is a
/// number even though it is represented as XML text.
fn opaque_drawing_object_ids(
  anchors : ArrayView[PreservedDrawingAnchor],
) -> Array[String] raise XlsxError {
  let ids : Array[String] = []
  let seen : Set[String] = Set([])
  for anchor in anchors {
    let wrapped = "" +
      anchor.xml +
      ""
    let scanner = @ooxml.XmlStartTagScanner::new(wrapped)
    while workbook_scanner_next(scanner) {
      if scanner.namespace_uri() != transitional_spreadsheet_drawing_namespace ||
        scanner.local_name() != "cNvPr" {
        continue
      }
      match workbook_scanner_attribute(scanner, "", "id") {
        Some(value) => {
          let key = normalized_drawing_object_id(value)
          if key != "" && !seen.contains(key) {
            seen.add(key)
            ids.push(key)
          }
        }
        None => ()
      }
    }
  }
  ids
}

///|
fn remap_preserved_drawing_relationship(
  relationship : PreservedDrawingRelationship,
  output_paths : Map[String, String],
) -> PreservedDrawingRelationship raise XlsxError {
  if relationship.external {
    return relationship
  }
  let source_target = match relationship.target_part {
    Some(value) => value
    None =>
      raise InvalidSheetOperation(
        msg="preserved drawing relationship target missing",
      )
  }
  let output_target = match
    output_paths.get(@ooxml.package_part_name_key(source_target)) {
    Some(value) => value
    None =>
      raise InvalidSheetOperation(
        msg="preserved drawing relationship closure incomplete",
      )
  }
  {
    ..relationship
    // A package-absolute target is valid OPC and avoids depending on the
    // source part's former directory depth after isolation.
    ,
    target: "/" + output_target,
    target_part: Some(output_target),
  }
}

///|
fn preserved_drawing_write_plan(
  sheet : Worksheet,
  sheet_id : Int,
) -> PreservedDrawingWritePlan raise XlsxError {
  let output_paths : Map[String, String] = Map([])
  for index, part in sheet.preserved_drawing_parts {
    let key = @ooxml.package_part_name_key(part.source_path)
    if output_paths.contains(key) {
      raise InvalidSheetOperation(msg="duplicate preserved drawing part")
    }
    output_paths[key] = "xl/preservedDrawingParts/drawing\{sheet_id}/part\{index + 1}.bin"
  }
  let parts : Array[PreservedDrawingWritePart] = []
  for part in sheet.preserved_drawing_parts {
    let path = output_paths
      .get(@ooxml.package_part_name_key(part.source_path))
      .unwrap()
    let relationships : Array[PreservedDrawingRelationship] = []
    for relationship in part.relationships {
      relationships.push(
        remap_preserved_drawing_relationship(relationship, output_paths),
      )
    }
    parts.push({
      path,
      data: part.data,
      content_type: part.content_type,
      relationships,
    })
  }
  let drawing_relationships : Array[PreservedDrawingRelationship] = []
  let relationship_ids : Array[String] = []
  let relationship_ids_seen : Set[String] = Set([])
  for relationship in sheet.preserved_drawing_relationships {
    if relationship_ids_seen.contains(relationship.id) {
      raise InvalidSheetOperation(
        msg="duplicate preserved drawing relationship id",
      )
    }
    relationship_ids_seen.add(relationship.id)
    relationship_ids.push(relationship.id)
    drawing_relationships.push(
      remap_preserved_drawing_relationship(relationship, output_paths),
    )
  }
  {
    anchors: sheet.preserved_drawing_anchors.copy(),
    drawing_relationships,
    parts,
    relationship_ids,
    object_ids: opaque_drawing_object_ids(sheet.preserved_drawing_anchors),
  }
}

///|
fn collect_preserved_drawing_parts(
  initial_paths : ArrayView[String],
  archive : @zip.Archive,
  part_names : Map[String, String],
  content_types : @ooxml.PackageContentTypes,
  decode : (BytesView) -> String raise XlsxError,
  budget? : ReadBudget,
  cancelled? : () -> Bool = () => false,
) -> Array[PreservedDrawingPart] raise XlsxError {
  let parts : Array[PreservedDrawingPart] = []
  let queue : Array[String] = []
  queue.append(initial_paths)
  let seen : Set[String] = Set([])
  let mut cursor = 0
  while cursor < queue.length() {
    match budget {
      Some(value) => value.checkpoint()
      None => ()
    }
    let source_path = queue[cursor]
    cursor = cursor + 1
    let key = @ooxml.package_part_name_key(source_path)
    if seen.contains(key) {
      continue
    }
    seen.add(key)
    let actual_path = actual_archive_part_path(part_names, source_path)
    let data = match archive.get(actual_path) {
      Some(value) => value.to_owned()
      None => raise MissingPart(path=actual_path)
    }
    let content_type = declared_part_content_type(
      content_types,
      source_path,
      "preserved drawing part",
      cancelled~,
    )
    let relationships = match
      load_optional_relationship_part(
        archive,
        content_types,
        actual_relationship_part_path(actual_path, part_names),
        "preserved drawing relationships",
        cancelled~,
      ) {
      Some(bytes) =>
        preserved_drawing_relationships_from_xml(
          decode(bytes),
          source_path,
          part_names,
          budget?,
          cancelled~,
        )
      None => []
    }
    for relationship in relationships {
      match relationship.target_part {
        Some(target_part) => queue.push(target_part)
        None => ()
      }
    }
    match budget {
      Some(value) => value.charge_items(1)
      None => ()
    }
    parts.push({ source_path, data, content_type, relationships })
  }
  parts
}

///|
fn preserved_unsupported_drawing_state(
  anchors : ArrayView[DrawingAnchor],
  drawing_rels_xml : StringView,
  drawing_part : StringView,
  archive : @zip.Archive,
  part_names : Map[String, String],
  content_types : @ooxml.PackageContentTypes,
  decode : (BytesView) -> String raise XlsxError,
  budget? : ReadBudget,
  cancelled? : () -> Bool = () => false,
) -> (
  Array[PreservedDrawingAnchor],
  Array[PreservedDrawingRelationship],
  Array[PreservedDrawingPart],
) raise XlsxError {
  let opaque_anchors : Array[PreservedDrawingAnchor] = []
  let required_ids : Array[String] = []
  let required_ids_seen : Set[String] = Set([])
  for anchor in anchors {
    if !anchor.unsupported {
      continue
    }
    opaque_anchors.push({ order: anchor.order, xml: anchor.xml })
    for id in opaque_drawing_relationship_ids(anchor.xml, cancelled~) {
      if !required_ids_seen.contains(id) {
        required_ids_seen.add(id)
        required_ids.push(id)
      }
    }
  }
  if opaque_anchors.length() == 0 || required_ids.length() == 0 {
    return (opaque_anchors, [], [])
  }
  if drawing_rels_xml == "" {
    raise InvalidXml(msg="unsupported drawing relationship missing")
  }
  let all_relationships = preserved_drawing_relationships_from_xml(
    drawing_rels_xml,
    drawing_part,
    part_names,
    budget?,
    cancelled~,
  )
  let relationship_indices : Map[String, Int] = Map([])
  for index, relationship in all_relationships {
    relationship_indices[relationship.id] = index
  }
  let direct_relationships : Array[PreservedDrawingRelationship] = []
  let initial_paths : Array[String] = []
  let initial_seen : Set[String] = Set([])
  for id in required_ids {
    let relationship = match relationship_indices.get(id) {
      Some(index) => all_relationships[index]
      None => raise InvalidXml(msg="unsupported drawing relationship missing")
    }
    direct_relationships.push(relationship)
    match relationship.target_part {
      Some(path) => {
        let key = @ooxml.package_part_name_key(path)
        if !initial_seen.contains(key) {
          initial_seen.add(key)
          initial_paths.push(path)
        }
      }
      None => ()
    }
  }
  let parts = collect_preserved_drawing_parts(
    initial_paths,
    archive,
    part_names,
    content_types,
    decode,
    budget?,
    cancelled~,
  )
  (opaque_anchors, direct_relationships, parts)
}