///|
let read_office = "urn:oasis:names:tc:opendocument:xmlns:office:1.0#"

///|
let read_table = "urn:oasis:names:tc:opendocument:xmlns:table:1.0#"

///|
let read_text = "urn:oasis:names:tc:opendocument:xmlns:text:1.0#"

///|
/// Coordinates are zero-based. -1 means workbook/sheet scope.
pub(all) struct ImportDiagnostic {
  sheet : String
  row : Int32
  col : Int32
  code : String
  message : String
} derive(Eq, Debug)

///|
struct ImportResult {
  book : Workbook
  notices : Array[ImportDiagnostic]
}

///|
pub fn ImportResult::workbook(self : ImportResult) -> Workbook {
  self.book
}

///|
/// A copy: callers cannot mutate the reader's diagnostic record.
pub fn ImportResult::diagnostics(
  self : ImportResult,
) -> Array[ImportDiagnostic] {
  self.notices.copy()
}

///|
pub fn Workbook::sheet(self : Workbook, index : Int32) -> Sheet? {
  if index < 0 || index >= self.sheets.length() {
    None
  } else {
    Some(self.sheets[index])
  }
}

///|
pub fn Sheet::dimensions(self : Sheet) -> (Int32, Int32) {
  (self.rows, self.cols)
}

///|
pub fn Sheet::merge_span(
  self : Sheet,
  row : Int32,
  col : Int32,
) -> (Int32, Int32)? {
  self.spans.get((row, col))
}

///|
pub fn Sheet::is_covered(self : Sheet, row : Int32, col : Int32) -> Bool {
  self.covered.contains((row, col))
}

///|
fn ReadElement::elements(
  self : ReadElement,
) -> Array[ReadElement] raise OdsError {
  let result = []
  for part in self.parts {
    match part {
      ReadText(t) =>
        if t.trim() != "" {
          raise Invalid("unexpected XML character data")
        }
      ReadElement(e) => result.push(e)
    }
  }
  result
}

///|
fn ReadElement::attribute(
  self : ReadElement,
  ns : String,
  key : String,
) -> String? {
  self.attrs.get(ns + key)
}

///|
fn ReadElement::allowed(
  self : ReadElement,
  names : Array[String],
) -> Unit raise OdsError {
  for k, _ in self.attrs {
    if !names.contains(k) {
      raise Invalid("unsupported attribute: \{k}")
    }
  }
}

///|
fn read_positive(raw : String, max : Int32) -> Int32 raise OdsError {
  if raw == "" {
    raise Invalid("empty repeat/span count")
  }
  let mut n = 0
  for c in raw {
    let d = c.to_int() - 48
    if d < 0 || d > 9 || n > (max - d) / 10 {
      raise Invalid("repeat/span exceeds budget")
    }
    n = n * 10 + d
    if n > max {
      raise Invalid("repeat/span exceeds budget")
    }
  }
  if n < 1 {
    raise Invalid("repeat/span must be positive")
  }
  n
}

///|
fn ReadElement::count(
  self : ReadElement,
  ns : String,
  key : String,
  max : Int32,
) -> Int32 raise OdsError {
  match self.attribute(ns, key) {
    Some(s) => read_positive(s, max)
    None => 1
  }
}

///|
fn read_text_parts(node : ReadElement) -> String raise OdsError {
  let b = StringBuilder()
  let mut units = 0
  for part in node.parts {
    let s = match part {
      ReadText(t) => t
      ReadElement(e) =>
        if e.name == read_text + "s" {
          e.allowed([read_text + "c"])
          if !e.parts.is_empty() {
            raise Invalid("text:s must be empty")
          }
          let n = e.count(read_text, "c", 32767)
          let spaces = StringBuilder()
          for _ in 0.. 32767 {
      raise Invalid("expanded cell text exceeds budget")
    }
    b.write_string(s)
  }
  b.to_string()
}

///|
fn read_cell_text(node : ReadElement) -> String raise OdsError {
  let b = StringBuilder()
  let mut first = true
  let mut units = 0
  for e in node.elements() {
    if e.name != read_text + "p" {
      raise Invalid("unsupported cell child: \{e.name}")
    }
    e.allowed([])
    if !first {
      b.write_char('\n')
      units += 1
    }
    first = false
    let s = read_text_parts(e)
    units += s.length()
    if units > 32767 {
      raise Invalid("expanded cell text exceeds budget")
    }
    b.write_string(s)
  }
  b.to_string()
}

///|
fn read_decimal_identity(raw : String) -> String raise OdsError {
  // Exact decimal normalization for already lexically validated finite inputs.
  // This compares source/output decimals; it does not claim decimal arithmetic.
  let digits : Array[Char] = []
  let mut negative = false
  let mut fractional = false
  let mut fraction_digits = 0
  let mut exponent = false
  let mut exponent_negative = false
  let mut power = 0
  for i, c in raw {
    if !exponent {
      if i == 0 && c == '-' {
        negative = true
      } else if c == '+' {
        ()
      } else if c == '.' {
        fractional = true
      } else if c == 'e' || c == 'E' {
        exponent = true
      } else {
        digits.push(c)
        if fractional {
          fraction_digits += 1
        }
      }
    } else if c == '-' {
      exponent_negative = true
    } else if c == '+' {
      ()
    } else {
      if power > 100000 {
        raise Invalid("numeric exponent outside supported decimal range")
      }
      power = power * 10 + c.to_int() - 48
    }
  }
  let mut first = 0
  while first < digits.length() && digits[first] == '0' {
    first += 1
  }
  if first == digits.length() {
    return if negative { "-0" } else { "0" }
  }
  let mut last = digits.length()
  while last > first && digits[last - 1] == '0' {
    last -= 1
  }
  let scale = (if exponent_negative { -power } else { power }) -
    fraction_digits +
    digits.length() -
    last
  let b = StringBuilder()
  if negative {
    b.write_char('-')
  }
  for i in first.. Double raise OdsError {
  if raw.length() > 1024 {
    raise Invalid("numeric literal exceeds 1024 units")
  }
  // XML Schema decimal/exponent lexical subset; core parse_double also accepts
  // language-specific underscores, which must not be admitted as ODF numbers.
  let chars : Array[Char] = []
  for c in raw {
    chars.push(c)
  }
  let n = chars.length()
  let mut i = 0
  if i < n && (chars[i] == '+' || chars[i] == '-') {
    i += 1
  }
  let mut digits = 0
  while i < n && chars[i] >= '0' && chars[i] <= '9' {
    i += 1
    digits += 1
  }
  if i < n && chars[i] == '.' {
    i += 1
    while i < n && chars[i] >= '0' && chars[i] <= '9' {
      i += 1
      digits += 1
    }
  }
  if digits == 0 {
    raise Invalid("invalid ODF numeric lexical form")
  }
  if i < n && (chars[i] == 'e' || chars[i] == 'E') {
    i += 1
    if i < n && (chars[i] == '+' || chars[i] == '-') {
      i += 1
    }
    let start = i
    while i < n && chars[i] >= '0' && chars[i] <= '9' {
      i += 1
    }
    if start == i {
      raise Invalid("invalid ODF numeric exponent")
    }
  }
  if i != n {
    raise Invalid("invalid ODF numeric lexical form")
  }
  let result = @string.parse_double(raw[:]) catch {
    _ => raise Invalid("invalid numeric cell")
  }
  if result.is_nan() || result.is_inf() {
    raise Invalid("number must be finite")
  }
  if result == 0.0 {
    let mut mantissa = true
    for c in raw {
      if c == 'e' || c == 'E' {
        mantissa = false
      }
      if mantissa && c >= '1' && c <= '9' {
        raise Invalid("nonzero numeric input underflows to zero")
      }
    }
  }
  result
}

///|
fn read_value(node : ReadElement) -> Value raise OdsError {
  // Validate even display paragraphs on non-text values; never silently discard
  // annotations, drawings, links, or rich text embedded in numeric cells.
  let text = read_cell_text(node)
  let kind = node.attribute(read_office, "value-type")
  match
    node.attrs.get(
      "urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0#value-type",
    ) {
    Some(ext) =>
      if Some(ext) != kind {
        raise Invalid("unsupported or conflicting LibreOffice value type")
      }
    None => ()
  }
  let value = match kind {
    None => {
      if text != "" {
        raise Invalid("untyped nonempty cell")
      }
      Empty
    }
    Some("string") =>
      match node.attribute(read_office, "string-value") {
        Some(s) => {
          if text != "" && text != s {
            raise Invalid("string value/display conflict")
          }
          Text(s)
        }
        None => Text(text)
      }
    Some("float") => {
      let raw = node.attribute(read_office, "value").unwrap_or("")
      Number(read_finite_double(raw))
    }
    Some("boolean") =>
      match node.attribute(read_office, "boolean-value") {
        Some("true") | Some("1") => Boolean(true)
        Some("false") | Some("0") => Boolean(false)
        _ => raise Invalid("invalid boolean cell")
      }
    Some("date") => {
      let raw = node.attribute(read_office, "date-value").unwrap_or("")
      let p = raw.split("-").map(x => x.to_owned()).collect()
      if p.length() != 3 ||
        p[0].length() != 4 ||
        p[1].length() != 2 ||
        p[2].length() != 2 {
        raise Invalid("only Gregorian YYYY-MM-DD dates supported")
      }
      Date(
        read_positive(p[0], 9999),
        read_positive(p[1], 12),
        read_positive(p[2], 31),
      )
    }
    Some(other) => raise Invalid("unsupported ODF value type: \{other}")
  }
  // Reject conflicting alternate typed attributes instead of coercing them.
  for field in ["value", "boolean-value", "date-value", "string-value"] {
    if node.attribute(read_office, field) is Some(_) {
      let expected = match kind {
        Some("float") => "value"
        Some("boolean") => "boolean-value"
        Some("date") => "date-value"
        Some("string") => "string-value"
        _ => ""
      }
      if field != expected {
        raise Invalid("conflicting typed value attributes")
      }
    }
  }
  validate_value(value)
  value
}

///|
fn read_notice(
  notices : Array[ImportDiagnostic],
  diagnostic : ImportDiagnostic,
) -> Unit raise OdsError {
  if notices.length() >= 8192 {
    raise Invalid("import diagnostic budget exceeded")
  }
  notices.push(diagnostic)
}

///|
fn ReadElement::style_notice(
  self : ReadElement,
  notices : Array[ImportDiagnostic],
  sheet : String,
  row : Int32,
  col : Int32,
) -> Unit raise OdsError {
  if self.attribute(read_table, "style-name") is Some(_) ||
    self.attribute(read_table, "default-cell-style-name") is Some(_) {
    read_notice(notices, {
      sheet,
      row,
      col,
      code: "FORMATTING_DROPPED",
      message: "Source formatting is not retained by data-only import.",
    })
  }
}

///|
fn read_workbook_budget(book : Workbook) -> Unit raise OdsError {
  let mut grids = 0
  let mut units = 0
  for s in book.sheets {
    if s.rows * s.cols > 65536 {
      raise Invalid("sheet grid exceeds 65536 cells")
    }
    grids += s.rows * s.cols
    units += s.text_units
    if grids > 262144 || units > 4194304 {
      raise Invalid("workbook resource budget exceeded during import")
    }
  }
}

///|
fn import_table(
  node : ReadElement,
  book : Workbook,
  notices : Array[ImportDiagnostic],
) -> Unit raise OdsError {
  node.allowed([read_table + "name", read_table + "style-name"])
  let name = node.attribute(read_table, "name").unwrap_or("")
  let sheet = book.add_sheet(name)
  node.style_notice(notices, name, -1, -1)
  let mut row = 0
  let mut declared_cols = 0
  for child in node.elements() {
    if child.name == read_table + "table-column" {
      child.allowed([
        read_table + "style-name",
        read_table + "default-cell-style-name",
        read_table + "number-columns-repeated",
      ])
      if !child.parts.is_empty() || row != 0 {
        raise Invalid("unsupported table column placement/content")
      }
      let n = child.count(read_table, "number-columns-repeated", 128)
      if declared_cols > 128 - n {
        raise Invalid("column declaration exceeds limit")
      }
      child.style_notice(notices, name, -1, declared_cols)
      declared_cols += n
      sheet.cols = declared_cols
      read_workbook_budget(book)
      continue
    }
    if child.name != read_table + "table-row" {
      raise Invalid("unsupported table child: \{child.name}")
    }
    child.allowed([
      read_table + "number-rows-repeated",
      read_table + "style-name",
      read_table + "default-cell-style-name",
    ])
    let rows = child.count(read_table, "number-rows-repeated", 1024) catch {
      Invalid(msg) => raise Invalid("sheet '\{name}' row \{row} col 0: \{msg}")
    }
    if row > 1024 - rows {
      raise Invalid("row repeat exceeds grid")
    }
    if row + rows > sheet.rows {
      sheet.rows = row + rows
    }
    read_workbook_budget(book)
    let cells = child.elements()
    for r in row..<(row + rows) {
      child.style_notice(notices, name, r, -1)
      let mut col = 0
      for node in cells {
        let is_covered = node.name == read_table + "covered-table-cell"
        if !is_covered && node.name != read_table + "table-cell" {
          raise Invalid("unsupported row child")
        }
        let cols = node.count(read_table, "number-columns-repeated", 128) catch {
          Invalid(msg) =>
            raise Invalid("sheet '\{name}' row \{r} col \{col}: \{msg}")
        }
        if col > 128 - cols {
          raise Invalid("column repeat exceeds grid")
        }
        for c in col..<(col + cols) {
          try {
            if is_covered {
              node.allowed([read_table + "number-columns-repeated"])
              if !node.parts.is_empty() || !sheet.is_covered(r, c) {
                raise Invalid("unexpected/nonempty covered cell")
              }
            } else {
              node.allowed([
                read_table + "number-columns-repeated",
                read_table + "number-columns-spanned",
                read_table + "number-rows-spanned",
                read_table + "formula",
                read_table + "style-name",
                read_office + "value-type",
                read_office + "value",
                read_office + "boolean-value",
                read_office + "date-value",
                read_office + "string-value",
                "urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0#value-type",
              ])
              let value = read_value(node)
              match value {
                Number(n) => {
                  let raw = node.attribute(read_office, "value").unwrap_or("")
                  let stored = finite_number_text(n)
                  if read_decimal_identity(raw) != read_decimal_identity(stored) {
                    let preview = if raw.length() <= 96 {
                      raw
                    } else {
                      raw[:96].to_owned() + "…"
                    }
                    read_notice(notices, {
                      sheet: name,
                      row: r,
                      col: c,
                      code: "NUMBER_PRECISION_CHANGED",
                      message: "Source decimal \{preview} is stored as binary64 output \{stored}; exact decimal input is not retained.",
                    })
                  }
                }
                _ => ()
              }
              let cell = match node.attribute(read_table, "formula") {
                None => Cell::new(value)
                Some(f) => {
                  if node.ns.get("of") !=
                    Some("urn:oasis:names:tc:opendocument:xmlns:of:1.2") {
                    raise Invalid("formula namespace unsupported")
                  }
                  read_notice(notices, {
                    sheet: name,
                    row: r,
                    col: c,
                    code: "FORMULA_CACHE_NOT_RECALCULATED",
                    message: "Stored cache retained; edits do not recalculate this formula.",
                  })
                  Cell::formula(f, value)
                }
              }
              node.style_notice(notices, name, r, c)
              let cell = match value {
                Date(_, _, _) => cell.styled(DateISO)
                _ => cell
              }
              sheet.set(r, c, cell)
              read_workbook_budget(book)
              let nr = node.count(read_table, "number-rows-spanned", 1024)
              let nc = node.count(read_table, "number-columns-spanned", 128)
              if nr > 1 || nc > 1 {
                if rows != 1 || cols != 1 {
                  raise Invalid("repeated merged anchors unsupported")
                }
                sheet.merge(r, c, nr, nc)
                read_workbook_budget(book)
              }
            }
          } catch {
            Invalid(msg) =>
              raise Invalid("sheet '\{name}' row \{r} col \{c}: \{msg}")
          }
        }
        col += cols
      }
      // Missing covered positions must not be invented by the importer.
      for c in 0..= col {
          raise Invalid("missing covered cell")
        }
      }
    }
    row += rows
  }
  if sheet.rows > row && !sheet.spans.is_empty() {
    raise Invalid("merge extends beyond input rows")
  }
}

///|
/// Import a bounded, data-only ODS subset. The explicit opt-in acknowledges
/// formatting, application metadata and view settings are not retained.
/// Unsupported semantic features fail, with sheet/cell context where known.
/// No partial workbook is returned on failure; formulas are never evaluated.
pub fn read_ods(
  data : Bytes,
  allow_formatting_loss~ : Bool = false,
) -> ImportResult raise OdsError {
  if !allow_formatting_loss {
    raise Invalid(
      "data-only import requires allow_formatting_loss=true; source formatting and metadata will not be retained",
    )
  }
  let omitted : Array[String] = []
  let result = import_content(read_ods_content_noticing(data, omitted))
  for path in omitted {
    read_notice(result.notices, {
      sheet: "",
      row: -1,
      col: -1,
      code: "PACKAGE_PART_DROPPED",
      message: "Source package part \{path} is not preserved by data-only import.",
    })
  }
  result
}

///|
fn import_content(xml : String) -> ImportResult raise OdsError {
  let root = read_xml(xml)
  if root.name != read_office + "document-content" {
    raise Invalid("expected ODF document-content namespace")
  }
  root.allowed([read_office + "version"])
  let book = Workbook::new()
  let notices : Array[ImportDiagnostic] = [
    {
      sheet: "",
      row: -1,
      col: -1,
      code: "DATA_ONLY_IMPORT",
      message: "Formatting, metadata, view settings, and numeric display text are not retained. Numbers use binary64; changed decimal representations are reported. Supported merges and formula text/cache are retained.",
    },
  ]
  let mut bodies = 0
  for child in root.elements() {
    if child.name == read_office + "automatic-styles" ||
      child.name == read_office + "font-face-decls" {
      if !child.elements().is_empty() || !child.attrs.is_empty() {
        read_notice(notices, {
          sheet: "",
          row: -1,
          col: -1,
          code: "DOCUMENT_FORMATTING_DROPPED",
          message: "Source \{child.name} is not retained by data-only import.",
        })
      }
      continue
    }
    if child.name == read_office + "scripts" {
      if !child.elements().is_empty() || !child.attrs.is_empty() {
        raise Invalid("scripts unsupported")
      }
      continue
    }
    if child.name != read_office + "body" {
      raise Invalid("unsupported document feature: \{child.name}")
    }
    bodies += 1
    if bodies != 1 {
      raise Invalid("duplicate office body")
    }
    child.allowed([])
    let contents = child.elements()
    if contents.length() != 1 || contents[0].name != read_office + "spreadsheet" {
      raise Invalid("expected one spreadsheet")
    }
    contents[0].allowed([])
    for table in contents[0].elements() {
      if table.name == read_table + "named-expressions" &&
        table.elements().is_empty() &&
        table.attrs.is_empty() {
        continue
      }
      if table.name != read_table + "table" {
        raise Invalid("unsupported spreadsheet feature: \{table.name}")
      }
      import_table(table, book, notices)
    }
  }
  if bodies != 1 || book.sheet_count() == 0 {
    raise Invalid("missing spreadsheet body or sheets")
  }
  // Reuse writer preflight to enforce aggregate grid/text budgets as well.
  book.content_xml() |> ignore
  { book, notices, }
}

///|
pub extend ImportDiagnostic with Eq::{equal, not_equal}

///|
pub extend ImportDiagnostic with @debug.Debug::{to_repr}