///|
/// Spatial operations return explicit metadata-loss information.
pub(all) struct TransformResult {
  image : Image
  warnings : Array[String]
}

///|
fn Image::transform_header(
  self : Image,
  shape : Array[Int],
  drop_extensions : Bool,
) -> (FixedArray[Byte], Array[String]) raise NiftiError {
  require(
    self.intent == 0 || self.intent == 1002 || self.intent == 2001,
    "transform_intent",
  )
  require(self.qcode > 0 || self.scode > 0, "unknown_space")
  require(self.extension_count == 0 || drop_extensions, "opaque_extensions")
  let size = if drop_extensions { 352 } else { self.header.length() }
  let b = FixedArray::makei(size, i => self.header[i])
  let warnings = [
    "slice_timing_and_dim_info_cleared", "header_text_preserved_not_anonymized",
  ]
  if drop_extensions {
    for i in 348..<352 {
      b[i] = b'\x00'
    }
    put_f32(b, self.endian, 108, 352)
    if self.extension_count > 0 {
      warnings.push("opaque_extensions_dropped")
    }
  }
  for i in 0..<3 {
    put_i16(b, self.endian, 42 + 2 * i, shape[i])
  }
  for i in [39, 74, 75, 120, 121, 122, 132, 133, 134, 135] {
    b[i] = b'\x00'
  }
  (b, warnings)
}

///|
fn write_sform(
  b : FixedArray[Byte],
  e : Endian,
  a : Affine,
) -> Unit raise NiftiError {
  for i in 0..<12 {
    put_f32(b, e, 280 + 4 * i, a.values[i])
  }
}

///|
fn write_qoffset(
  b : FixedArray[Byte],
  e : Endian,
  a : Affine,
) -> Unit raise NiftiError {
  for i in 0..<3 {
    put_f32(b, e, 268 + 4 * i, a.values[4 * i + 3])
  }
}

///|
/// A spatial ROI uses half-open bounds [start, start + size), retaining all t.
/// Stored samples/scaling are unchanged; both defined spatial forms move independently.
pub fn Image::crop(
  self : Image,
  start : Array[Int],
  size : Array[Int],
  drop_extensions? : Bool = false,
) -> TransformResult raise NiftiError {
  require(start.length() == 3 && size.length() == 3, "crop_shape")
  for a in 0..<3 {
    require(
      start[a] >= 0 &&
      size[a] > 0 &&
      start[a] < self.shape[a] &&
      size[a] <= self.shape[a] - start[a],
      "crop_bounds",
    )
  }
  let mapping = Affine::new([
    1.0,
    0,
    0,
    start[0].to_double(),
    0,
    1,
    0,
    start[1].to_double(),
    0,
    0,
    1,
    start[2].to_double(),
    0,
    0,
    0,
    1,
  ])
  let (header, warnings) = self.transform_header(size, drop_extensions)
  if self.qform is Some(q) {
    write_qoffset(header, self.endian, q.compose(mapping))
  }
  if self.sform is Some(s) {
    write_sform(header, self.endian, s.compose(mapping))
  }
  let n = size[0] * size[1] * size[2] * self.shape[3]
  let payload = FixedArray::make(n * self.width, b'\x00')
  let mut dest = 0
  for t in 0..