///|
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[:].to_upper().to_owned() == "UTF-8" else {
    raise Invalid("only UTF-8 tag files are supported")
  }
  { version: "1.0", encoding: "UTF-8", }
}

///|
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()
}

///|
/// Conservative portable paths also rule out drive letters and UNC separators.
pub fn check_path(path : String) -> Unit raise FormatError {
  guard path != "" && !path.has_prefix("/") else {
    raise Invalid("path must be relative")
  }
  guard !path.contains("\\") && !path.contains(":") && !path.contains("\u0000") else {
    raise Invalid("unsafe or nonportable path")
  }
  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([])
  for entry in entries {
    check_path(entry.path)
    guard (entry.checksum.length() == 64 || entry.checksum.length() == 128) &&
      entry.checksum.iter().all(c => c.is_ascii_hexdigit()) else {
      raise Invalid("manifest writer requires a SHA-256 or SHA-512 hex digest")
    }
    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()
}

///|
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") }
    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()
      guard label != "" && label.trim(chars=" \t").to_owned() == label else {
        raise Invalid("invalid metadata 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 {
    guard field.label != "" &&
      !field.label.contains(":") &&
      !field.label.contains("\r") &&
      !field.label.contains("\n") &&
      field.label.trim(chars=" \t").to_owned() == field.label else {
      raise Invalid("invalid metadata label")
    }
    guard !field.value.contains("\r") else {
      raise Invalid("metadata values use LF for continuations")
    }
    let value = field.value.replace_all(old="\n", new="\n ")
    out.write_string(field.label + ": " + value + "\n")
  }
  out.to_string()
}

///|
fn take_token(row : String, start : Int) -> (String, Int) {
  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)
    guard url.find(":") is Some(colon) && colon > 0 else {
      raise Invalid("fetch URL must be an absolute URI")
    }
    let scheme = url[:colon].to_owned()
    let first = scheme[0]
    guard ((first >= 'a' && first <= 'z') || (first >= 'A' && first <= 'Z')) &&
      scheme
      .iter()
      .all(c => {
        (c >= 'a' && c <= 'z') ||
        (c >= 'A' && c <= 'Z') ||
        (c >= '0' && c <= '9') ||
        c == '+' ||
        c == '-' ||
        c == '.'
      }) else {
      raise Invalid("invalid URI scheme")
    }
    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
}