///|
let max_package_validation_findings = 256

///|
let package_validation_omission_message = "additional package findings were omitted after the bounded validator limit"

///|
fn push_package_problem(problems : Array[String], problem : String) -> Bool {
  if problems.length() < max_package_validation_findings - 1 {
    problems.push(problem)
    true
  } else {
    if problems.length() == max_package_validation_findings - 1 {
      problems.push(package_validation_omission_message)
    }
    false
  }
}

///|
fn package_content_type_is_xml(value : StringView) -> Bool {
  let lower = value.to_owned().to_lower()
  let media_type = match lower.find(";") {
    Some(index) => lower[:index].trim()
    None => lower.trim()
  }
  media_type == "application/xml" ||
  media_type == "text/xml" ||
  media_type.has_suffix("+xml")
}

///|
fn package_validation_content_types(
  archive : @zip.Archive,
) -> @ooxml.PackageContentTypes? {
  match archive.get("[Content_Types].xml") {
    Some(data) =>
      Some(@ooxml.parse_package_content_types(decode_package_part(data))) catch {
        _ => None
      }
    None => None
  }
}

///|
fn charge_package_validation_xml_part(
  data : BytesView,
  limits : ReadLimits,
  total_xml_bytes : Ref[Int],
  markup_tokens : Ref[Int],
) -> Unit raise XlsxError {
  if data.length() > limits.max_xml_part_bytes {
    raise ResourceLimitExceeded(
      kind="xml_part_bytes",
      limit=limits.max_xml_part_bytes,
      actual=data.length(),
    )
  }
  if data.length() > limits.max_total_xml_bytes - total_xml_bytes.val {
    raise ResourceLimitExceeded(
      kind="total_xml_bytes",
      limit=limits.max_total_xml_bytes,
      actual=bounded_actual_above_limit(limits.max_total_xml_bytes),
    )
  }
  total_xml_bytes.val = total_xml_bytes.val + data.length()
  for byte in data {
    if byte == b'<' {
      if markup_tokens.val >= limits.max_xml_markup_tokens {
        raise ResourceLimitExceeded(
          kind="xml_markup_tokens",
          limit=limits.max_xml_markup_tokens,
          actual=bounded_actual_above_limit(limits.max_xml_markup_tokens),
        )
      }
      markup_tokens.val = markup_tokens.val + 1
    }
  }
}

///|
/// Applies the same aggregate XML-byte and markup-token policy as the complete
/// workbook reader before package validation starts decoding or scanning
/// package plumbing. XML identity follows the package content-type manifest,
/// with conventional XML-like suffixes retained as a fail-closed fallback.
/// Counting every XML package part makes the validation boundary deterministic
/// and prevents many individually-small parts from bypassing the aggregate read
/// policy.
fn enforce_package_validation_xml_limits(
  archive : @zip.Archive,
  limits : ReadLimits,
) -> Unit raise XlsxError {
  let total_xml_bytes = Ref(0)
  let markup_tokens = Ref(0)
  // Enforce suffix-identifiable XML first. This includes the content-types
  // manifest itself, so its byte and scan-work ceilings are charged before the
  // manifest parser is allowed to inspect it.
  for entry in archive.entries() {
    if is_xml_package_part(entry.name()) {
      charge_package_validation_xml_part(
        entry.data(),
        limits,
        total_xml_bytes,
        markup_tokens,
      )
    }
  }
  let content_types = package_validation_content_types(archive)
  for entry in archive.entries() {
    if is_xml_package_part(entry.name()) {
      continue
    }
    let declared_xml = match content_types {
      Some(index) => {
        let logical_name = logical_archive_part_path(entry.name())
        let declared = index.content_type_for(logical_name) catch { _ => None }
        match declared {
          Some(value) => package_content_type_is_xml(value)
          None => false
        }
      }
      None => false
    }
    if declared_xml {
      charge_package_validation_xml_part(
        entry.data(),
        limits,
        total_xml_bytes,
        markup_tokens,
      )
    }
  }
}

///|
/// Checks the structural invariants an OOXML (xlsx) package must satisfy
/// for Microsoft Excel to open it without a repair prompt, returning the
/// list of problems found (empty when the package is well-formed). These
/// are the package-level rules the OOXML schema validator does not fully
/// cover and that are the common causes of Excel's "we found a problem"
/// dialog:
///
///  - the archive is a readable zip
///  - `[Content_Types].xml` and the root `_rels/.rels` exist
///  - every part is covered by a Default (by extension) or an Override
///    content type
///  - every relationship target (except external ones) resolves to a
///    part that exists in the package
///  - no duplicate part names, and part names are well-formed
///  - the core workbook parts are present
///
/// This is a fast, dependency-free complement to the Microsoft OpenXML
/// SDK validator: it runs entirely in MoonBit, so it can be asserted on
/// every workbook a test generates. An unreadable ZIP is returned as a stable
/// finding; resource-policy violations raise `ResourceLimitExceeded`. Findings
/// are capped at 256 while scanning, with a final omission marker when more
/// defects exist, so diagnostics cannot become an attacker-sized second tree.
pub fn validate_ooxml_package(
  bytes : BytesView,
  limits? : ReadLimits = ReadLimits::new(),
) -> Array[String] raise XlsxError {
  let archive = read_limited_archive(bytes, limits) catch {
    InvalidPackage(_) => return ["archive is not a readable zip"]
    error => raise error
  }
  validate_ooxml_bounded_archive(archive, limits~) catch {
    InvalidPackage(_) => ["archive is not a readable zip"]
    error => raise error
  }
}

///|
/// Validates an already-inflated OOXML package without decompressing it again.
/// The same pristine bounded-provenance contract as `read_bounded_archive`
/// applies.
pub fn validate_ooxml_bounded_archive(
  archive : @zip.Archive,
  limits? : ReadLimits = ReadLimits::new(),
) -> Array[String] raise XlsxError {
  require_bounded_archive(archive, limits)
  enforce_package_validation_xml_limits(archive, limits)
  preflight_archive_relationship_limits(archive, limits)
  validate_ooxml_archive(archive)
}

///|
fn validate_ooxml_archive(
  archive : @zip.Archive,
) -> Array[String] raise XlsxError {
  let problems : Array[String] = []
  let parts : Map[String, Int] = Map([])
  for entry in archive.entries() {
    let name = entry.name()
    parts[name] = parts.get(name).unwrap_or(0) + 1
    if name.contains("\\") {
      if !push_package_problem(problems, "part name uses a backslash: \{name}") {
        return problems
      }
    }
    if name.has_prefix("/") {
      if !push_package_problem(
          problems,
          "part name has a leading slash: \{name}",
        ) {
        return problems
      }
    }
  }
  for name, count in parts {
    if count > 1 {
      if !push_package_problem(
          problems,
          "duplicate part: \{name} (\{count} entries)",
        ) {
        return problems
      }
    }
  }

  // required core parts
  for
    required in [
      "[Content_Types].xml", "_rels/.rels", "xl/workbook.xml", "xl/_rels/workbook.xml.rels",
    ] {
    if !parts.contains(required) {
      if !push_package_problem(problems, "missing required part: \{required}") {
        return problems
      }
    }
  }

  // content-type coverage
  match archive.get("[Content_Types].xml") {
    Some(data) => {
      let ct = decode_package_part(data)
      let (defaults, overrides) = parse_content_types(ct)
      for entry in archive.entries() {
        let name = entry.name()
        if name == "[Content_Types].xml" {
          continue
        }
        let has_override = overrides.contains("/" + name)
        let has_default = match part_extension(name) {
          Some(ext) => defaults.contains(ext.to_lower())
          None => false
        }
        if !has_override && !has_default {
          if !push_package_problem(
              problems,
              "part has no declared content type: \{name}",
            ) {
            return problems
          }
        }
      }
    }
    None => ()
  }

  // relationship target integrity
  for entry in archive.entries() {
    let name = entry.name()
    if !is_relationship_package_part(name) {
      continue
    }
    let base = rels_base_dir(name)
    let rels_xml = decode_package_part(entry.data())
    // Process one relationship at a time. The aggregate token preflight above
    // bounds scan work, while avoiding a second target array proportional to
    // attacker-controlled relationship count.
    for_each_relationship_start_tag(rels_xml, scanner => {
      let target_opt = relationship_scanner_attribute(scanner, "Target") catch {
        _ => None
      }
      match target_opt {
        Some(target) => {
          let mode_opt = relationship_scanner_attribute(scanner, "TargetMode") catch {
            _ => None
          }
          let external = match mode_opt {
            Some(mode) => mode == "External"
            None => false
          }
          if !external {
            let resolved = resolve_part_path(base, target)
            if resolved != "" && !parts.contains(resolved) {
              let source_text = package_diagnostic_excerpt(name, 160)
              let target_text = package_diagnostic_excerpt(target, 160)
              let resolved_text = package_diagnostic_excerpt(resolved, 160)
              if !push_package_problem(
                  problems,
                  "relationship in \{source_text} points to a missing part: \{target_text} -> \{resolved_text}",
                ) {
                return false
              }
            }
          }
        }
        None => ()
      }
      true
    })
    if problems.length() == max_package_validation_findings {
      return problems
    }
  }
  problems
}

///|
/// Decodes a package part as UTF-8, tolerating a leading BOM. Used only
/// for the ASCII-structured package plumbing (content types, rels).
fn decode_package_part(data : BytesView) -> String {
  @encoding/utf8.decode(data, ignore_bom=true) catch {
    _ => ""
  }
}

///|
/// Returns the lowercase-ready file extension of a part name, or `None`.
fn part_extension(name : String) -> String? {
  match name.rev_find(".") {
    Some(dot) =>
      match name.rev_find("/") {
        Some(slash) =>
          if slash > dot {
            None
          } else {
            Some(name[dot + 1:].to_owned())
          }
        None => Some(name[dot + 1:].to_owned())
      }
    None => None
  }
}

///|
/// Parses `[Content_Types].xml` into the set of Default extensions
/// (lowercased) and the set of Override part names.
fn parse_content_types(xml : String) -> (Map[String, Bool], Map[String, Bool]) {
  let defaults : Map[String, Bool] = Map([])
  let overrides : Map[String, Bool] = Map([])
  let mut first = true
  for chunk in xml.split("") {
      Some(pos) => pos
      None => continue
    }
    let ext = attr_value(chunk[:end], "Extension") catch { _ => None }
    match ext {
      Some(ext) => defaults[ext.to_lower()] = true
      None => ()
    }
  }
  first = true
  for chunk in xml.split("") {
      Some(pos) => pos
      None => continue
    }
    let part = attr_value(chunk[:end], "PartName") catch { _ => None }
    match part {
      Some(part) => overrides[part] = true
      None => ()
    }
  }
  (defaults, overrides)
}

///|
/// The base directory a relationship target in `/_rels/.rels`
/// resolves against: the directory containing the described part. For
/// the root `_rels/.rels` this is the package root ("").
fn rels_base_dir(rels_part : String) -> String {
  match rels_part.rev_find("_rels/") {
    Some(pos) => rels_part[:pos].to_owned()
    None => ""
  }
}

///|
/// Resolves a relationship target against a base directory, applying a
/// leading-slash absolute form and `.`/`..` segments. Returns "" when
/// the path escapes the package root (which the caller treats as
/// unresolvable rather than a definite error).
fn package_diagnostic_excerpt(value : StringView, maximum : Int) -> String {
  if value.length() <= maximum {
    value.to_owned()
  } else {
    value[:maximum].to_owned() + "…"
  }
}

///|
fn resolve_part_path(base : StringView, target : StringView) -> String {
  let segments : Array[StringView] = []
  fn consume(value : StringView) -> Bool {
    for segment in value.split("/") {
      if segment == "" || segment == "." {
        continue
      }
      if segment == ".." {
        if segments.length() == 0 {
          return false
        }
        ignore(segments.pop())
      } else {
        segments.push(segment)
      }
    }
    true
  }
  if target.has_prefix("/") {
    if !consume(target[1:]) {
      return ""
    }
  } else if !consume(base) || !consume(target) {
    return ""
  }
  let result = StringBuilder::new()
  for index, segment in segments {
    if index > 0 {
      result.write_char('/')
    }
    result.write_view(segment)
  }
  result.to_string()
}

///|
test "resolve_part_path handles real xlsx relationship shapes" {
  // worksheet rels -> drawing (../ climb)
  inspect(
    resolve_part_path("xl/worksheets/", "../drawings/drawing1.xml"),
    content="xl/drawings/drawing1.xml",
  )
  // workbook rels -> worksheet (plain descend)
  inspect(
    resolve_part_path("xl/", "worksheets/sheet1.xml"),
    content="xl/worksheets/sheet1.xml",
  )
  // root rels -> workbook
  inspect(resolve_part_path("", "xl/workbook.xml"), content="xl/workbook.xml")
  // package-absolute target
  inspect(
    resolve_part_path("xl/worksheets/", "/xl/media/image1.png"),
    content="xl/media/image1.png",
  )
  // ./ no-op segment
  inspect(
    resolve_part_path("xl/", "./theme/theme1.xml"),
    content="xl/theme/theme1.xml",
  )
  // escape-root returns "" (treated as skip, not error)
  inspect(resolve_part_path("", "../outside.xml"), content="")
}

///|
test "rels_base_dir and part_extension" {
  inspect(
    rels_base_dir("xl/worksheets/_rels/sheet1.xml.rels"),
    content="xl/worksheets/",
  )
  inspect(rels_base_dir("_rels/.rels"), content="")
  inspect(rels_base_dir("xl/_rels/workbook.xml.rels"), content="xl/")
  debug_inspect(
    part_extension("xl/worksheets/sheet1.xml"),
    content=(
      #|Some("xml")
    ),
  )
  debug_inspect(
    part_extension("xl/media/image1.PNG"),
    content=(
      #|Some("PNG")
    ),
  )
  debug_inspect(
    part_extension("_rels/.rels"),
    content=(
      #|Some("rels")
    ),
  )
  // no extension after last slash
  debug_inspect(part_extension("xl/theme"), content="None")
}