///|
fn linear_space(c : Char) -> Bool {
  c == ' ' || c == '\t'
}

///|
fn linear_unit(c : UInt16) -> Bool {
  c == ' ' || c == '\t'
}

///|
fn lines(text : String) -> Array[String] raise FormatError {
  guard !text.has_prefix("\uFEFF") else {
    raise Invalid("UTF-8 text must not contain a BOM")
  }
  let normalized = text
    .replace_all(old="\r\n", new="\n")
    .replace_all(old="\r", new="\n")
  let result = normalized.split("\n").map(v => v.to_owned()).collect()
  if result.length() > 0 && result[result.length() - 1] == "" {
    ignore(result.pop())
  }
  result
}

///|
pub fn parse_declaration(text : String) -> Declaration raise FormatError {
  let rows = lines(text)
  guard rows.length() == 2 else {
    raise Invalid("bagit.txt requires exactly two lines")
  }
  guard rows[0] == "BagIt-Version: 1.0" else {
    raise Invalid("only BagIt-Version: 1.0 is supported")
  }
  guard rows[1].has_prefix("Tag-File-Character-Encoding: ") else {
    raise Invalid("missing or misplaced character encoding declaration")
  }
  let encoding = rows[1][29:].to_owned()
  guard encoding != "" &&
    encoding
    .iter()
    .all(c => {
      uri_alphanumeric(c) || c == '-' || c == '_' || c == '.' || c == ':'
    }) else {
    raise Invalid("invalid character encoding name")
  }
  {
    version: "1.0",
    encoding: if encoding[:].to_upper().to_owned() == "UTF-8" {
      "UTF-8"
    } else {
      encoding
    },
  }
}

///|
pub fn encode_path(path : String) -> String {
  path
  .replace_all(old="%", new="%25")
  .replace_all(old="\r", new="%0D")
  .replace_all(old="\n", new="%0A")
}

///|
pub fn decode_path(path : String) -> String raise FormatError {
  let out = StringBuilder()
  let mut i = 0
  let mut start = 0
  while i < path.length() {
    if path[i] == '%' {
      out.write_string(path[start:i].to_owned())
      guard i + 2 < path.length() else {
        raise Invalid("truncated percent escape")
      }
      let first = path[i + 1]
      let second = path[i + 2]
      if first == '2' && second == '5' {
        out.write_string("%")
      } else if first == '0' && (second == 'D' || second == 'd') {
        out.write_string("\r")
      } else if first == '0' && (second == 'A' || second == 'a') {
        out.write_string("\n")
      } else {
        raise Invalid("only %, CR and LF may be percent encoded")
      }
      i = i + 3
      start = i
    } else {
      i = i + 1
    }
  }
  out.write_string(path[start:].to_owned())
  out.to_string()
}

///|
/// Format paths are POSIX-relative; portability is a separate advisory check.
pub fn check_path(path : String) -> Unit raise FormatError {
  guard path != "" && !path.has_prefix("/") else {
    raise Invalid("path must be relative")
  }
  guard !path.contains("\u0000") &&
    !(path.length() >= 2 &&
    path[1] == ':' &&
    ((path[0] >= 'a' && path[0] <= 'z') || (path[0] >= 'A' && path[0] <= 'Z'))) else {
    raise Invalid("NUL and drive-qualified paths are unsafe")
  }
  for part in path.split("/") {
    guard part != "" && part != "." && part != ".." else {
      raise Invalid("empty, dot and parent path components are forbidden")
    }
  }
}

///|
fn valid_checksum(value : String, alg : Algorithm) -> Bool {
  value.length() == alg.hex_length() &&
  value.iter().all(c => c.is_ascii_hexdigit())
}

///|
pub fn parse_manifest(
  name : String,
  text : String,
) -> Manifest raise FormatError {
  let tag = name.has_prefix("tagmanifest-")
  let prefix = if tag { "tagmanifest-" } else { "manifest-" }
  guard name.has_prefix(prefix) &&
    name.has_suffix(".txt") &&
    !name.contains("/") else {
    raise Invalid("invalid manifest filename")
  }
  let alg = algorithm(name[prefix.length():name.length() - 4].to_owned())
  let entries : Array[Entry] = []
  let seen : Map[String, Bool] = Map([])
  for row in lines(text) {
    guard row.find_by(linear_space) is Some(start) else {
      raise Invalid("manifest separator missing")
    }
    let checksum = row[:start].to_owned()
    guard valid_checksum(checksum, alg) else {
      raise Invalid("invalid checksum length or hexadecimal value")
    }
    let mut end = start
    while end < row.length() && linear_unit(row[end]) {
      end = end + 1
    }
    let path = decode_path(row[end:].to_owned())
    check_path(path)
    if tag {
      guard !path.has_prefix("data/") && path != "data" else {
        raise Invalid("tag manifest references payload")
      }
      guard !(path.has_prefix("tagmanifest-") &&
        path.has_suffix(".txt") &&
        !path.contains("/")) else {
        raise Invalid("tag manifest references a tag manifest")
      }
    } else {
      guard path.has_prefix("data/") else {
        raise Invalid("payload manifest references a tag file")
      }
    }
    guard !seen.contains(path) else {
      raise Invalid("duplicate manifest path: " + path)
    }
    seen[path] = true
    entries.push({ path, checksum: checksum[:].to_lower().to_owned(), })
  }
  { name, algorithm: alg, tag, entries, }
}

///|
pub fn render_manifest(entries : Array[Entry]) -> String raise FormatError {
  let out = StringBuilder()
  let seen : Map[String, Bool] = Map([])
  let mut digest_length = 0
  for entry in entries {
    check_path(entry.path)
    guard !linear_unit(entry.path[0]) else {
      raise Invalid(
        "leading path whitespace is ambiguous with manifest separators",
      )
    }
    guard [32, 40, 64, 128].contains(entry.checksum.length()) &&
      entry.checksum.iter().all(c => c.is_ascii_hexdigit()) else {
      raise Invalid(
        "manifest writer requires an MD5, SHA-1, SHA-256 or SHA-512 hex digest",
      )
    }
    guard digest_length == 0 || digest_length == entry.checksum.length() else {
      raise Invalid("one manifest cannot mix checksum algorithms")
    }
    digest_length = entry.checksum.length()
    guard !seen.contains(entry.path) else {
      raise Invalid("duplicate manifest path")
    }
    seen[entry.path] = true
    out.write_string(entry.checksum + "  " + encode_path(entry.path) + "\n")
  }
  out.to_string()
}

///|
fn metadata_label(label : String) -> Unit raise FormatError {
  guard label != "" &&
    !label.contains(":") &&
    label.trim(chars=" \t").to_owned() == label &&
    !label.contains("\r") &&
    !label.contains("\n") else {
    raise Invalid(
      "metadata label must not contain boundary whitespace, a colon or line endings",
    )
  }
}

///|
fn metadata_value(value : String) -> Unit raise FormatError {
  guard !value.contains("\r") else {
    raise Invalid("use LF for metadata continuation values")
  }
}

///|
pub fn parse_metadata(text : String) -> Array[Metadata] raise FormatError {
  let result : Array[Metadata] = []
  for row in lines(text) {
    guard row != "" else { raise Invalid("empty metadata line") }
    metadata_value(row)
    if linear_unit(row[0]) {
      guard result.length() > 0 else {
        raise Invalid("metadata continuation without a field")
      }
      let i = result.length() - 1
      let previous = result[i]
      result[i] = {
        label: previous.label,
        value: previous.value + "\n" + row.trim_start(chars=" \t").to_owned(),
      }
    } else {
      guard row.find(":") is Some(colon) else {
        raise Invalid("metadata colon missing")
      }
      let label = row[:colon].to_owned()
      metadata_label(label)
      guard colon + 1 < row.length() && linear_unit(row[colon + 1]) else {
        raise Invalid("metadata requires whitespace after colon")
      }
      result.push({ label, value: row[colon + 2:].to_owned(), })
    }
  }
  result
}

///|
pub fn render_metadata(fields : Array[Metadata]) -> String raise FormatError {
  let out = StringBuilder()
  for field in fields {
    metadata_label(field.label)
    metadata_value(field.value)
    let parts = field.value.split("\n").collect()
    for i in 1.. (String, Int32) {
  let mut i = start
  while i < row.length() && !linear_unit(row[i]) {
    i = i + 1
  }
  let token = row[start:i].to_owned()
  while i < row.length() && linear_unit(row[i]) {
    i = i + 1
  }
  (token, i)
}

///|
pub fn parse_fetch(text : String) -> Array[FetchEntry] raise FormatError {
  let result : Array[FetchEntry] = []
  let seen : Map[String, Bool] = Map([])
  for row in lines(text) {
    let (url, next) = take_token(row, 0)
    let (length, end) = take_token(row, next)
    check_fetch_url(url)
    let size = if length == "-" {
      None
    } else {
      guard length != "" && length.iter().all(c => c >= '0' && c <= '9') else {
        raise Invalid("invalid fetch size")
      }
      let size = @string.parse_int64(length[:]) catch {
        _ => raise Invalid("fetch size overflows Int64")
      }
      Some(size)
    }
    let path = decode_path(row[end:].to_owned())
    check_path(path)
    guard path.has_prefix("data/") else {
      raise Invalid("fetch entry must be a payload file")
    }
    guard !seen.contains(path) else { raise Invalid("duplicate fetch path") }
    seen[path] = true
    result.push({ url, size, path, })
  }
  result
}

///|
/// Serialize an offline fetch plan; this function never downloads its URIs.
pub fn render_fetch(entries : Array[FetchEntry]) -> String raise FormatError {
  let out = StringBuilder()
  let seen : Map[String, Bool] = Map([])
  for entry in entries {
    check_fetch_url(entry.url)
    check_path(entry.path)
    guard entry.path.has_prefix("data/") else {
      raise Invalid("fetch entry must be a payload file")
    }
    guard !seen.contains(entry.path) else {
      raise Invalid("duplicate fetch path")
    }
    seen[entry.path] = true
    let size = match entry.size {
      None => "-"
      Some(size) => {
        guard size >= 0L else { raise Invalid("negative fetch size") }
        size.to_string()
      }
    }
    out.write_string(
      entry.url + " " + size + " " + encode_path(entry.path) + "\n",
    )
  }
  out.to_string()
}