///|
fn parameter_key(key : String) -> (Int, String)? raise FcsError {
  let k = key.to_upper()
  if !k.has_prefix("$P") || k.length() < 4 {
    return None
  }
  let mut i = 2
  while i < k.length() && k[i].to_int() >= 48 && k[i].to_int() <= 57 {
    i = i + 1
  }
  if i == 2 || i == k.length() {
    return None
  }
  Some((natural(k[2:i].to_owned()), k[i:].to_owned()))
}

///|
fn padded(n : Int, width : Int) -> String raise FcsError {
  let s = n.to_string()
  if n < 0 || s.length() > width {
    raise Limit("offset field overflow")
  }
  String::make(width - s.length(), '0') + s
}

///|
fn assemble(
  meta : Array[(String, String)],
  raw : Bytes,
  analysis : Array[(String, String)],
) -> Bytes raise FcsError {
  let a = if analysis.is_empty() { b"" } else { encode_text(analysis) }
  put(meta, "$BEGINSTEXT", "0")
  put(meta, "$ENDSTEXT", "0")
  put(meta, "$NEXTDATA", "0")
  for key in ["$BEGINDATA", "$ENDDATA", "$BEGINANALYSIS", "$ENDANALYSIS"] {
    put(meta, key, "0000000000")
  }
  let size = encode_text(meta).length()
  let start = 256 + size
  let stop = start + raw.length() - 1
  let ast = if a.is_empty() { 0 } else { stop + 1 }
  let ae = if a.is_empty() { 0 } else { ast + a.length() - 1 }
  put(meta, "$BEGINDATA", padded(start, 10))
  put(meta, "$ENDDATA", padded(stop, 10))
  put(meta, "$BEGINANALYSIS", padded(ast, 10))
  put(meta, "$ENDANALYSIS", padded(ae, 10))
  let txt = encode_text(meta)
  if txt.length() != size {
    raise Invalid("internal TEXT layout mismatch")
  }
  let hdrfield = fn(n) raise FcsError {
    padded(if n <= 99999999 { n } else { 0 }, 8)
  }
  let hdr = @utf8.encode(
    "FCS3.1    " +
    padded(256, 8) +
    padded(255 + size, 8) +
    hdrfield(start) +
    hdrfield(stop) +
    hdrfield(ast) +
    hdrfield(ae),
  )
  let total = start + raw.length() + a.length()
  if total > 268435456 {
    raise Limit("output exceeds 256 MiB")
  }
  Bytes::makei(total, i => {
    if i < 58 {
      hdr[i]
    } else if i < 256 {
      b' '
    } else if i < start {
      txt[i - 256]
    } else if i <= stop {
      raw[i - start]
    } else {
      a[i - ast]
    }
  })
}

///|
fn edits(
  meta : Array[(String, String)],
  updates : Array[(String, String)],
) -> Unit raise FcsError {
  let reserved = [
    "$BEGINDATA", "$ENDDATA", "$BEGINANALYSIS", "$ENDANALYSIS", "$BEGINSTEXT", "$ENDSTEXT",
    "$NEXTDATA", "$BYTEORD", "$DATATYPE", "$MODE", "$PAR", "$TOT", "$SPILL", "$SPILLOVER",
    "$COMP", "$CRC", "$ORIGINALITY",
  ]
  for (k, v) in updates {
    if reserved.contains(k.to_upper()) || parameter_key(k) is Some(_) {
      raise Invalid("structural keyword cannot be edited: \{k}")
    }
    put(meta, k, v)
  }
}

///|
/// Creates a standalone canonical FCS 3.1 dataset. Event bytes are not decoded/re-encoded.
/// Changing events/channels with ANALYSIS requires explicit drop_analysis=true.
pub fn Dataset::write(
  self : Dataset,
  event_indices? : Array[Int],
  channel_indices? : Array[Int],
  updates? : Array[(String, String)] = [],
  drop_spillover? : Bool = false,
  drop_analysis? : Bool = false,
) -> Bytes raise FcsError {
  let es = event_indices.unwrap_or_else(() => Array::makei(self.events, i => i))
  let cs = channel_indices.unwrap_or_else(() => {
    Array::makei(self.channels.length(), i => i)
  })
  if cs.is_empty() ||
    cs.length() > self.channels.length() ||
    es.length() > 8000000 / cs.length() {
    raise Limit("output event/channel count")
  }
  let seen : Map[Int, Bool] = Map([])
  for c in cs {
    self.check_channel(c)
    if seen.contains(c) {
      raise Invalid("duplicate output channel")
    }
    seen[c] = true
  }
  for e in es {
    if e < 0 || e >= self.events {
      raise Invalid("selected event out of bounds")
    }
  }
  let changed_channels = cs.length() != self.channels.length() ||
    cs.mapi((i, c) => i != c).contains(true)
  let changed_events = es.length() != self.events ||
    es.mapi((i, e) => i != e).contains(true)
  if (changed_channels || changed_events) &&
    !self.analysis.is_empty() &&
    !drop_analysis {
    raise Invalid(
      "selection may invalidate ANALYSIS; explicitly request drop_analysis",
    )
  }
  if changed_channels && self.keyword("$COMP") != None && !drop_spillover {
    raise Invalid(
      "legacy COMP requires explicit removal before channel changes",
    )
  }
  if self.spillover() is Some(s) && !drop_spillover {
    for name in s.channels {
      if !cs.map(i => self.channels[i].name).contains(name) {
        raise Invalid(
          "selection removes spillover channel; explicitly request drop_spillover",
        )
      }
    }
  }
  let meta = []
  for (key, value) in self.metadata {
    let k = key.to_upper()
    if k == "$CRC" ||
      k == "$UNICODE" ||
      (drop_spillover && ["$SPILL", "$SPILLOVER", "$COMP"].contains(k)) {
      continue
    }
    match parameter_key(k) {
      Some((old, suffix)) =>
        for i = 0; i < cs.length(); i = i + 1 {
          if cs[i] == old - 1 {
            meta.push(("$P\{i + 1}\{suffix}", value))
          }
        }
      None => meta.push((k, value))
    }
  }
  // Normalize only required layout/amplification fields. Unknown metadata is retained.
  for i = 0; i < cs.length(); i = i + 1 {
    let c = self.channels[cs[i]]
    put(meta, "$P\{i+1}E", "\{c.decades},\{c.zero}")
  }
  put(
    meta,
    "$BYTEORD",
    if self.order == LittleEndian {
      "1,2,3,4"
    } else {
      "4,3,2,1"
    },
  )
  put(meta, "$PAR", cs.length().to_string())
  put(meta, "$TOT", es.length().to_string())
  put(meta, "$ORIGINALITY", "Non-Original")
  edits(meta, updates)
  let row_offsets : Array[Int] = []
  for c in cs {
    for j = 0; j < self.channels[c].bits / 8; j = j + 1 {
      row_offsets.push(self.offsets[c] + j)
    }
  }
  let width = row_offsets.length()
  let raw = Bytes::makei(es.length() * width, i => {
    self.raw[es[i / width] * self.width + row_offsets[i % width]]
  })
  assemble(meta, raw, if drop_analysis { [] } else { self.analysis })
}

///|
/// Event-major values. Input is finite; Float32 overflow is rejected.
pub fn create(
  names : Array[String],
  values : Array[Double],
  double_precision? : Bool = false,
  order? : ByteOrder = LittleEndian,
  metadata? : Array[(String, String)] = [],
) -> Bytes raise FcsError {
  if names.is_empty() ||
    names.length() > 1024 ||
    values.length() > 8000000 ||
    values.length() % names.length() != 0 {
    raise Invalid("creation shape")
  }
  let meta = [
    ("$MODE", "L"),
    ("$DATATYPE", if double_precision { "D" } else { "F" }),
    ("$BYTEORD", if order == LittleEndian { "1,2,3,4" } else { "4,3,2,1" }),
    ("$PAR", names.length().to_string()),
    ("$TOT", (values.length() / names.length()).to_string()),
    ("$ORIGINALITY", "Non-Original"),
  ]
  let seen : Map[String, Bool] = Map([])
  for i = 0; i < names.length(); i = i + 1 {
    let name = names[i]
    if name.is_empty() || seen.contains(name) {
      raise Invalid("creation requires unique nonempty channel names")
    }
    seen[name] = true
    put(meta, "$P\{i+1}N", name)
    put(meta, "$P\{i+1}B", if double_precision { "64" } else { "32" })
    put(meta, "$P\{i+1}R", "262144")
    put(meta, "$P\{i+1}E", "0,0")
  }
  edits(meta, metadata)
  let out : Array[Byte] = []
  for v in values {
    if !finite(v) {
      raise Invalid("creation requires finite values")
    }
    let bits = if double_precision {
      v.reinterpret_as_uint64()
    } else {
      let f = Float::from_double(v)
      if !finite(f.to_double()) {
        raise Invalid("Float32 value overflow")
      }
      f.reinterpret_as_uint().to_uint64()
    }
    emit_word(out, bits, if double_precision { 8 } else { 4 }, order)
  }
  assemble(meta, Bytes::from_array(out), [])
}