///|
let office_read_max_package_bytes : Int = 64 * 1024 * 1024

///|
let office_read_max_zip_entries : Int = 4096

///|
let office_read_max_zip_entry_bytes : Int = 32 * 1024 * 1024

///|
let office_read_max_zip_total_bytes : Int = 128 * 1024 * 1024

///|
let office_read_max_preserved_source_bytes : Int = 64 * 1024 * 1024 + 65_535

///|
let office_read_max_xml_part_bytes : Int = 16 * 1024 * 1024

///|
let office_read_max_xml_total_units : Int = 64 * 1024 * 1024

///|
let office_read_max_workbook_sheets : Int = 256

///|
let office_read_max_parser_items : Int = 500_000

///|
let office_read_max_parser_work_units : Int = 256 * 1024 * 1024

///|
priv struct OfficeReadPackage {
  file : String
  format : @lib.DocumentFormat
  bytes : Bytes
  archive : @zip.Archive
}

///|
/// Reports cancellation that was delivered at an async suspension boundary.
/// The bounded synchronous package phases do not themselves run the scheduler.
fn office_async_cancelled() -> Bool {
  @async.is_being_cancelled()
}

///|
fn check_office_read_cancelled(cancelled : () -> Bool) -> Unit raise CliFailure {
  if cancelled() {
    raise CliFailure(
      @lib.protocol_error("office.cancelled", "Office read was cancelled"),
    )
  }
}

///|
fn detect_office_archive_format(
  file : String,
  archive : @zip.Archive,
  cancelled : () -> Bool,
) -> @lib.DocumentFormat raise CliFailure {
  check_office_read_cancelled(cancelled)
  let format = @lib.detect_archive_format(
    file,
    archive,
    max_xml_part_bytes=office_read_max_xml_part_bytes,
    max_xml_total_units=office_read_max_xml_total_units,
    cancelled~,
  ) catch {
    ResourceLimit(kind~, limit~, actual~) =>
      raise CliFailure(
        @lib.protocol_error(
          "office.\{office_read_format_hint(file).name()}.resource_limit",
          "Office \{bounded_text(kind, 80)} exceeds the configured limit",
          details=Json::object({
            "file": Json::string(bounded_text(file, 160)),
            "resource": Json::string(bounded_text(kind, 80)),
            "limit": Json::number(limit.to_double()),
            "actual": Json::number(actual.to_double()),
          }),
        ),
      )
    Cancelled => {
      check_office_read_cancelled(cancelled)
      raise CliFailure(
        @lib.protocol_error("office.cancelled", "Office read was cancelled"),
      )
    }
    error => raise CliFailure(identify_error(error, file))
  }
  check_office_read_cancelled(cancelled)
  format
}

///|
fn office_read_format_hint(
  file : String,
) -> @lib.DocumentFormat raise CliFailure {
  let lower = file.to_lower()
  if lower.has_suffix(".xlsx") {
    Xlsx
  } else if lower.has_suffix(".docx") {
    Docx
  } else {
    raise CliFailure(identify_error(UnsupportedFileExtension(file), file))
  }
}

///|
fn zip_limit_kind_name(kind : @zip.LimitKind) -> String {
  match kind {
    PackageBytes => "package_bytes"
    Entries => "entry_count"
    EntryUncompressedBytes => "entry_uncompressed_bytes"
    TotalUncompressedBytes => "total_uncompressed_bytes"
    PreservedSourceBytes => "total_preserved_source_bytes"
    OutputBytes => "output_bytes"
  }
}

///|
fn office_zip_read_failure(
  error : @zip.ZipError,
  file : String,
  expected : @lib.DocumentFormat,
) -> CliFailure {
  match error {
    ZipError(LimitExceeded(OutputBytes, limit, _actual), _) =>
      format_resource_failure(expected.name(), "ZIP output", limit)
    ZipError(LimitExceeded(kind, limit, actual), _) =>
      CliFailure(
        @lib.protocol_error(
          "office.\{expected.name()}.resource_limit",
          "\{expected.name().to_upper()} ZIP \{bounded_text(zip_limit_kind_name(kind), 80)} exceeds the configured limit",
          details=Json::object({
            "file": Json::string(bounded_text(file, 160)),
            "resource": Json::string(
              bounded_text(zip_limit_kind_name(kind), 80),
            ),
            "limit": Json::number(limit.to_double()),
            "actual": Json::number(actual.to_double()),
          }),
        ),
      )
    _ =>
      CliFailure(
        @lib.protocol_error(
          "office.invalid_package",
          "invalid Office package: archive is not a readable bounded ZIP",
          details=Json::object({ "file": Json::string(bounded_text(file, 160)) }),
        ),
      )
  }
}

///|
fn office_xlsx_read_limits() -> @xlsx.ReadLimits raise @xlsx.XlsxError {
  @xlsx.ReadLimits::with_values(
    max_package_bytes=office_read_max_package_bytes,
    max_archive_entries=office_read_max_zip_entries,
    max_entry_uncompressed_bytes=office_read_max_zip_entry_bytes,
    max_total_uncompressed_bytes=office_read_max_zip_total_bytes,
    max_total_preserved_source_bytes=office_read_max_preserved_source_bytes,
    max_xml_part_bytes=office_read_max_xml_part_bytes,
    max_workbook_sheets=office_read_max_workbook_sheets,
    max_parser_items=office_read_max_parser_items,
    max_parser_work_units=office_read_max_parser_work_units,
  )
}

///|
async fn read_office_package(
  file : String,
  cancelled? : () -> Bool = () => false,
) -> OfficeReadPackage {
  check_office_read_cancelled(cancelled)
  let expected = office_read_format_hint(file)
  let data = read_bounded_file(
    file,
    office_read_max_package_bytes,
    "office.file_read_failed",
    "input package",
    limit_code="office.\{expected.name()}.resource_limit",
  )
  let archive = @zip.read(
    data,
    limits=@zip.ReadLimits::default()
      .with_package_limit(office_read_max_package_bytes)
      .with_entries_limit(office_read_max_zip_entries)
      .with_entry_limit(office_read_max_zip_entry_bytes)
      .with_total_limit(office_read_max_zip_total_bytes)
      .with_preserved_source_limit(office_read_max_preserved_source_bytes),
    cancelled~,
  ) catch {
    error if cancelled() => raise error
    error => raise office_zip_read_failure(error, file, expected)
  }
  let format = detect_office_archive_format(file, archive, cancelled)
  { file, format, bytes: data, archive, }
}

///|
fn xlsx_read_failure(error : @xlsx.XlsxError, file : String) -> CliFailure {
  fn invalid_package(message : String) -> CliFailure {
    CliFailure(
      @lib.protocol_error(
        "office.invalid_package",
        "invalid XLSX package: " + bounded_text(message, 320),
        details=Json::object({ "file": Json::string(bounded_text(file, 160)) }),
      ),
    )
  }
  match error {
    ResourceLimitExceeded(kind~, limit~, actual~) =>
      CliFailure(
        @lib.protocol_error(
          "office.xlsx.resource_limit",
          "XLSX \{bounded_text(kind, 80)} exceeds the configured limit",
          details=Json::object({
            "file": Json::string(bounded_text(file, 160)),
            "resource": Json::string(bounded_text(kind, 80)),
            "limit": Json::number(limit.to_double()),
            "actual": Json::number(actual.to_double()),
          }),
        ),
      )
    InvalidPackage(msg~) | InvalidXml(msg~) => invalid_package(msg)
    MissingPart(path~) => invalid_package("missing required part: \{path}")
    InvalidSharedString(index~) =>
      invalid_package("shared string index is out of range: \{index}")
    InvalidStyleId(index~) =>
      invalid_package("cell style index is out of range: \{index}")
    ReadCancelled =>
      CliFailure(
        @lib.protocol_error("office.cancelled", "XLSX read was cancelled"),
      )
    _ =>
      CliFailure(
        @lib.protocol_error(
          "office.xlsx.read_failed",
          "could not read XLSX workbook",
          details=Json::object({ "file": Json::string(bounded_text(file, 160)) }),
        ),
      )
  }
}

///|
fn open_xlsx_read_package(
  source : OfficeReadPackage,
  cancelled? : () -> Bool = () => false,
) -> @xlsx.Workbook raise {
  if source.format is Docx {
    raise CliFailure(
      @lib.protocol_error(
        "office.xlsx.format_mismatch", "XLSX reader received a validated DOCX package",
      ),
    )
  }
  let limits = office_xlsx_read_limits() catch {
    error => raise xlsx_read_failure(error, source.file)
  }
  @xlsx.read_bounded_archive(source.archive, limits~, cancelled~) catch {
    ReadCancelled as error => raise error
    error => raise xlsx_read_failure(error, source.file)
  }
}