///|
pub(all) enum DocumentFormat {
  Docx
  Xlsx
  Pptx
} derive(Debug, Eq)

///|
pub(all) struct DocumentCapabilities {
  render_scene : Bool
  save_as_copy : Bool
  replace_text : Bool
  set_cell_text : Bool
} derive(Debug, Eq)

///|
pub(all) struct DocumentDiagnostic {
  code : String
  message : String
  part : String?
} derive(Debug, Eq)

///|
pub(all) suberror DocumentSessionError {
  UnsupportedFormat(String)
  Package(String)
  Render(String)
  OperationNotSupported(operation~ : String, format~ : DocumentFormat)
  InvalidFind(String)
  InvalidCoordinates(row~ : Int, column~ : Int)
  SheetNotFound(String)
  FormulaCell(sheet~ : String, row~ : Int, column~ : Int)
  MergedCellNotAnchor(sheet~ : String, row~ : Int, column~ : Int)
  SaveVerification(String)
} derive(Debug, Eq)

///|
pub struct DocumentSession {
  priv filename : String
  priv format : DocumentFormat
  priv archive_policy : ArchivePolicy
  priv source_archive : @opc.Package
  priv scene : @scene.Document
  priv diagnostics : Array[DocumentDiagnostic]
  priv revision : Int
  priv dirty : Bool
}

///|
pub struct VerifiedDocumentCopy {
  priv bytes : FixedArray[Byte]
  priv session : DocumentSession
}

///|
pub fn DocumentFormat::capabilities(
  self : DocumentFormat,
) -> DocumentCapabilities {
  match self {
    Docx | Pptx =>
      {
        render_scene: true,
        save_as_copy: true,
        replace_text: true,
        set_cell_text: false,
      }
    Xlsx =>
      {
        render_scene: true,
        save_as_copy: true,
        replace_text: false,
        set_cell_text: true,
      }
  }
}

///|
fn format_from_filename(
  filename : String,
) -> DocumentFormat raise DocumentSessionError {
  let lower = filename.to_lower()
  if lower.has_suffix(".docx") {
    Docx
  } else if lower.has_suffix(".xlsx") {
    Xlsx
  } else if lower.has_suffix(".pptx") {
    Pptx
  } else {
    raise DocumentSessionError::UnsupportedFormat(filename)
  }
}

///|
fn render_archive(
  archive : @opc.Package,
  format : DocumentFormat,
) -> @scene.Document raise DocumentSessionError {
  match format {
    Docx =>
      @scene.Document::TextDocument(@docx.read(archive)) catch {
        error => raise DocumentSessionError::Render("\{Repr(error)}")
      }
    Xlsx =>
      @scene.Document::WorkbookDocument(@xlsx.read(archive)) catch {
        error => raise DocumentSessionError::Render("\{Repr(error)}")
      }
    Pptx =>
      @scene.Document::PresentationDocument(@pptx.read(archive)) catch {
        error => raise DocumentSessionError::Render("\{Repr(error)}")
      }
  }
}

///|
fn normalized_diagnostics(scene : @scene.Document) -> Array[DocumentDiagnostic] {
  let warnings = match scene {
    TextDocument(document) => document.warnings
    WorkbookDocument(workbook) => workbook.warnings
    PresentationDocument(presentation) => presentation.warnings
  }
  warnings.map(fn(warning) {
    { code: warning.code, message: warning.message, part: warning.part }
  })
}

///| Opens and renders an OOXML editing session with conservative ZIP/OPC

///|
/// limits, mandatory CRC verification, revision zero, and a clean baseline.
pub fn open_session(
  filename : String,
  content : FixedArray[Byte],
) -> DocumentSession raise DocumentSessionError {
  open_session_with_policy(filename, content, @opc.ArchivePolicy::default())
}

///|
/// Open a document session with explicit, validated ZIP/OPC limits.
pub fn open_session_with_policy(
  filename : String,
  content : FixedArray[Byte],
  policy : ArchivePolicy,
) -> DocumentSession raise DocumentSessionError {
  let format = format_from_filename(filename)
  let archive = @opc.open_with_policy(content, policy) catch {
    error => raise DocumentSessionError::Package(error.to_string())
  }
  let scene = render_archive(archive, format)
  {
    format,
    filename,
    archive_policy: policy,
    source_archive: archive,
    scene,
    diagnostics: normalized_diagnostics(scene),
    revision: 0,
    dirty: false,
  }
}

///|
pub fn DocumentSession::filename(self : DocumentSession) -> String {
  self.filename
}

///|
pub fn DocumentSession::format(self : DocumentSession) -> DocumentFormat {
  self.format
}

///|
pub fn DocumentSession::capabilities(
  self : DocumentSession,
) -> DocumentCapabilities {
  self.format.capabilities()
}

///|
fn clone_text_run(run : @scene.TextRun) -> @scene.TextRun {
  { text: run.text, style: run.style }
}

///|
fn clone_text_runs(runs : Array[@scene.TextRun]) -> Array[@scene.TextRun] {
  runs.map(clone_text_run)
}

///|
fn clone_text_block(block : @scene.TextBlock) -> @scene.TextBlock {
  match block {
    Paragraph(runs) => Paragraph(clone_text_runs(runs))
    Heading(level, runs) => Heading(level, clone_text_runs(runs))
    ListItem(level~, ordered~, runs) =>
      ListItem(level~, ordered~, clone_text_runs(runs))
    Table(rows) => Table(rows.map(fn(row) { row.copy() }))
  }
}

///|
fn clone_slide_element(element : @scene.SlideElement) -> @scene.SlideElement {
  match element {
    TextBox(bounds~, runs~) => TextBox(bounds~, runs=clone_text_runs(runs))
    Shape(kind~, bounds~, text~) =>
      Shape(kind~, bounds~, text=clone_text_runs(text))
    Image(bounds~, relationship_id~) => Image(bounds~, relationship_id~)
  }
}

///|
fn clone_scene(scene : @scene.Document) -> @scene.Document {
  match scene {
    TextDocument(document) =>
      TextDocument({
        blocks: document.blocks.map(clone_text_block),
        warnings: document.warnings.copy(),
      })
    WorkbookDocument(workbook) =>
      WorkbookDocument({
        sheets: workbook.sheets.map(fn(sheet) {
          {
            name: sheet.name,
            cells: sheet.cells.copy(),
            merged_ranges: sheet.merged_ranges.copy(),
          }
        }),
        warnings: workbook.warnings.copy(),
      })
    PresentationDocument(presentation) =>
      PresentationDocument({
        slides: presentation.slides.map(fn(slide) {
          {
            name: slide.name,
            width: slide.width,
            height: slide.height,
            elements: slide.elements.map(clone_slide_element),
          }
        }),
        warnings: presentation.warnings.copy(),
      })
  }
}

///|
pub fn DocumentSession::scene(self : DocumentSession) -> @scene.Document {
  clone_scene(self.scene)
}

///|
pub fn DocumentSession::diagnostics(
  self : DocumentSession,
) -> Array[DocumentDiagnostic] {
  self.diagnostics.copy()
}

///|
pub fn DocumentSession::revision(self : DocumentSession) -> Int {
  self.revision
}

///|
pub fn DocumentSession::is_dirty(self : DocumentSession) -> Bool {
  self.dirty
}

///|
fn DocumentSession::commit_candidate(
  self : DocumentSession,
  candidate : @opc.Package,
) -> DocumentSession raise DocumentSessionError {
  let scene = render_archive(candidate, self.format)
  if candidate == self.source_archive {
    return self
  }
  {
    format: self.format,
    filename: self.filename,
    archive_policy: self.archive_policy,
    source_archive: candidate,
    scene,
    diagnostics: normalized_diagnostics(scene),
    revision: self.revision + 1,
    dirty: true,
  }
}

///|
pub fn DocumentSession::replace_text(
  self : DocumentSession,
  find : String,
  replacement : String,
) -> DocumentSession raise DocumentSessionError {
  if find == "" {
    raise DocumentSessionError::InvalidFind("find text must not be empty")
  }
  match self.format {
    Xlsx =>
      raise OperationNotSupported(operation="replace_text", format=self.format)
    Docx | Pptx => ()
  }
  if find == replacement {
    return self
  }
  let candidate = match self.format {
    Docx =>
      @docx.replace_text(self.source_archive, find, replacement) catch {
        error => raise DocumentSessionError::Render("\{Repr(error)}")
      }
    Pptx =>
      @pptx.replace_text(self.source_archive, find, replacement) catch {
        error => raise DocumentSessionError::Render("\{Repr(error)}")
      }
    Xlsx =>
      raise DocumentSessionError::OperationNotSupported(
        operation="replace_text",
        format=self.format,
      )
  }
  self.commit_candidate(candidate)
}

///|
fn DocumentSession::validate_xlsx_cell(
  self : DocumentSession,
  sheet_name : String,
  row : Int,
  column : Int,
) -> Unit raise DocumentSessionError {
  if row < 1 || row > 1048576 || column < 1 || column > 16384 {
    raise DocumentSessionError::InvalidCoordinates(row~, column~)
  }
  let workbook = match self.scene {
    WorkbookDocument(workbook) => workbook
    _ =>
      raise DocumentSessionError::OperationNotSupported(
        operation="set_cell_text",
        format=self.format,
      )
  }
  let mut matched : @scene.Sheet? = None
  for candidate in workbook.sheets {
    if candidate.name == sheet_name {
      matched = Some(candidate)
      break
    }
  }
  let sheet = matched
  guard sheet is Some(sheet) else {
    raise DocumentSessionError::SheetNotFound(sheet_name)
  }
  for cell in sheet.cells {
    if cell.row == row && cell.column == column {
      match cell.value {
        Formula(..) =>
          raise DocumentSessionError::FormulaCell(
            sheet=sheet_name,
            row~,
            column~,
          )
        _ => ()
      }
    }
  }
  for range in sheet.merged_ranges {
    if row >= range.start_row &&
      row <= range.end_row &&
      column >= range.start_column &&
      column <= range.end_column &&
      (row != range.start_row || column != range.start_column) {
      raise DocumentSessionError::MergedCellNotAnchor(
        sheet=sheet_name,
        row~,
        column~,
      )
    }
  }
}

///|
fn DocumentSession::cell_text_equals(
  self : DocumentSession,
  sheet_name : String,
  row : Int,
  column : Int,
  text : String,
) -> Bool {
  match self.scene {
    WorkbookDocument(workbook) =>
      workbook.sheets.any(sheet => {
        sheet.name == sheet_name &&
        sheet.cells.any(cell => {
          cell.row == row &&
          cell.column == column &&
          (match cell.value {
            Text(existing) => existing == text
            _ => false
          })
        })
      })
    _ => false
  }
}

///|
pub fn DocumentSession::set_cell_text(
  self : DocumentSession,
  sheet : String,
  row : Int,
  column : Int,
  text : String,
) -> DocumentSession raise DocumentSessionError {
  if self.format != Xlsx {
    raise DocumentSessionError::OperationNotSupported(
      operation="set_cell_text",
      format=self.format,
    )
  }
  self.validate_xlsx_cell(sheet, row, column)
  if self.cell_text_equals(sheet, row, column, text) {
    return self
  }
  let candidate = @xlsx.set_cell_text(
    self.source_archive,
    sheet,
    row,
    column,
    text,
  ) catch {
    error => raise DocumentSessionError::Render("\{Repr(error)}")
  }
  self.commit_candidate(candidate)
}

///|
fn same_parts(left : @opc.Package, right : @opc.Package) -> Bool {
  if left.parts.length() != right.parts.length() {
    return false
  }
  left.parts.all(fn(part) {
    match right.part(part.name) {
      Some(saved) => saved.content == part.content
      None => false
    }
  })
}

///|
pub fn DocumentSession::save_as_copy(
  self : DocumentSession,
) -> VerifiedDocumentCopy raise DocumentSessionError {
  let bytes = @opc.save(self.source_archive) catch {
    error => raise DocumentSessionError::Package(error.to_string())
  }
  let reopened_package = @opc.open_with_policy(bytes, self.archive_policy) catch {
    error => raise DocumentSessionError::SaveVerification(error.to_string())
  }
  let reopened_scene = render_archive(reopened_package, self.format)
  if reopened_scene != self.scene {
    raise DocumentSessionError::SaveVerification(
      "semantic scene changed after reopen",
    )
  }
  if !same_parts(self.source_archive, reopened_package) {
    raise DocumentSessionError::SaveVerification(
      "OPC part names or payloads changed after reopen",
    )
  }
  {
    bytes,
    session: {
      format: self.format,
      filename: self.filename,
      archive_policy: self.archive_policy,
      source_archive: reopened_package,
      scene: reopened_scene,
      diagnostics: normalized_diagnostics(reopened_scene),
      revision: self.revision,
      dirty: false,
    },
  }
}

///|
pub fn VerifiedDocumentCopy::bytes(
  self : VerifiedDocumentCopy,
) -> FixedArray[Byte] {
  self.bytes.copy()
}

///|
pub fn VerifiedDocumentCopy::session(
  self : VerifiedDocumentCopy,
) -> DocumentSession {
  self.session
}