///|
pub fn digest(bytes : Bytes) -> String {
  let state = @sha256.Digest::new()
  for byte in bytes {
    state.write(byte)
  }
  state.check_sum()
}

///|
pub fn safe_path(path : String) -> Bool {
  if path == "" ||
    path.has_prefix("/") ||
    path.contains("\\") ||
    path.contains(":") {
    return false
  }
  for c in path {
    if c.to_int() < 32 || ['<', '>', '"', '|', '?', '*'].contains(c) {
      return false
    }
  }
  for part in path.split("/") {
    if part == "" ||
      part == "." ||
      part == ".." ||
      part.has_suffix(".") ||
      part.has_suffix(" ") {
      return false
    }
    let stem = part
      .to_string()
      .split(".")
      .head()
      .unwrap()
      .to_string()
      .to_lower()
    if [
        "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6",
        "com7", "com8", "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7",
        "lpt8", "lpt9",
      ].contains(stem) {
      return false
    }
  }
  true
}

///|
fn bytes_value(bytes : Bytes) -> Value {
  Array(bytes.iter().map(fn(b) { Number(b.to_int().to_double()) }).collect())
}

///|
fn decode_bytes(value : Value) -> Bytes raise {
  let values = arr(value)
  let bytes : Array[Byte] = []
  for i = 0; i < values.length(); i = i + 1 {
    let number = num(values[i])
    if number < 0.0 || number > 255.0 || number.floor() != number {
      raise InputError("invalid byte value")
    }
    bytes.push(number.to_int().to_byte())
  }
  Bytes::from_array(bytes)
}

///|
fn content(file : Value) -> Bytes raise {
  let text = get(file, "text")
  let bytes = get(file, "bytes")
  if text != Null && bytes != Null {
    raise InputError("file cannot provide text and bytes together")
  }
  if text != Null {
    @utf8.encode(str(text))
  } else {
    decode_bytes(bytes)
  }
}

///|
fn checksum(files : Array[Value]) -> String {
  digest(
    @utf8.encode(
      canonical(record([("version", Number(1.0)), ("files", Array(files))])),
    ),
  )
}

///|
fn manifest(snapshot : Value) -> Array[Value] raise {
  if get(snapshot, "version") != Number(1.0) {
    raise InputError("unsupported snapshot version")
  }
  let files = arr(get(snapshot, "files"))
  if get(snapshot, "checksum") != String(checksum(files)) {
    raise InputError("manifest checksum mismatch")
  }
  let paths : Map[String, Bool] = Map([])
  let names : Map[String, String] = Map([])
  for file in files {
    let path = str(get(file, "path"))
    if !safe_path(path) || paths.contains(path.to_lower()) {
      raise InputError("unsafe or duplicate snapshot path")
    }
    paths[path.to_lower()] = true
    let mut prefix = ""
    for part in path.split("/") {
      prefix += (if prefix == "" { "" } else { "/" }) + part.to_string()
      let key = prefix.to_lower()
      match names.get(key) {
        Some(existing) =>
          if existing != prefix {
            raise InputError("case-folded directory collision")
          }
        None => names[key] = prefix
      }
    }
    let size = num(get(file, "size"))
    if size < 0.0 || size.floor() != size {
      raise InputError("invalid file size")
    }
    let hash = str(get(file, "digest"))
    if hash.length() != 64 ||
      !hash
      .to_array()
      .all(fn(c) { (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') }) {
      raise InputError("invalid SHA-256 identifier")
    }
  }
  for file in files {
    let mut parent = str(get(file, "path"))
    while true {
      match parent.rev_split_once("/") {
        None => break
        Some((before, _)) => parent = before.to_string()
      }
      if paths.contains(parent.to_lower()) {
        raise InputError("file/directory path collision")
      }
    }
  }
  files
}

///|
pub fn audit_snapshot(snapshot : Value) -> Value raise {
  let files = manifest(snapshot)
  let objects = obj(get(snapshot, "objects"))
  let issues : Array[Value] = []
  let checked : Map[String, Bool] = Map([])
  for file in files {
    let hash = str(get(file, "digest"))
    let path = str(get(file, "path"))
    match objects.get(hash) {
      None =>
        issues.push(
          record([("path", String(path)), ("reason", String("missing object"))]),
        )
      Some(raw) => {
        let bytes = decode_bytes(raw)
        if Number(bytes.length().to_double()) != get(file, "size") {
          issues.push(
            record([("path", String(path)), ("reason", String("size mismatch"))]),
          )
        }
        if !checked.contains(hash) {
          if digest(bytes) != hash {
            issues.push(
              record([
                ("path", String(path)),
                ("reason", String("digest mismatch")),
              ]),
            )
          }
          checked[hash] = true
        }
      }
    }
  }
  record([
    ("valid", Bool(issues.is_empty())),
    ("issues", Array(issues)),
    ("files", Number(files.length().to_double())),
    ("objects_checked", Number(checked.length().to_double())),
  ])
}

///|
pub fn diff_snapshots(old : Value, new : Value) -> Value raise {
  let before : Map[String, Value] = Map([])
  let after : Map[String, Value] = Map([])
  for file in manifest(old) {
    before[str(get(file, "path"))] = file
  }
  for file in manifest(new) {
    after[str(get(file, "path"))] = file
  }
  let added : Array[String] = []
  let removed : Array[String] = []
  let modified : Array[String] = []
  for path, file in after {
    match before.get(path) {
      None => added.push(path)
      Some(previous) => if previous != file { modified.push(path) }
    }
  }
  for path, _ in before {
    if !after.contains(path) {
      removed.push(path)
    }
  }
  added.sort()
  removed.sort()
  modified.sort()
  record([
    ("added", strings(added)),
    ("removed", strings(removed)),
    ("modified", strings(modified)),
  ])
}

///|
pub fn create_snapshot(
  source : Array[Value],
  previous? : Value = Null,
) -> Value raise {
  let objects : Map[String, Value] = Map([])
  let files : Array[Value] = []
  let mut bytes_total = 0.0
  let mut unique_bytes = 0.0
  let mut new_bytes = 0.0
  let prior = if previous == Null {
    Map([])
  } else {
    if get(audit_snapshot(previous), "valid") != Bool(true) {
      raise InputError("previous snapshot is corrupted")
    }
    obj(get(previous, "objects"))
  }
  for file in source {
    let path = str(get(file, "path"))
    if !safe_path(path) {
      raise InputError("unsafe source path")
    }
    let bytes = content(file)
    let hash = digest(bytes)
    bytes_total += bytes.length().to_double()
    if !objects.contains(hash) {
      unique_bytes += bytes.length().to_double()
      objects[hash] = bytes_value(bytes)
      if !prior.contains(hash) {
        new_bytes += bytes.length().to_double()
      }
    }
    let mode = optional_num(get(file, "mode"), 420.0)
    if mode < 0.0 || mode > 511.0 || mode.floor() != mode {
      raise InputError("invalid file mode")
    }
    files.push(
      record([
        ("path", String(path)),
        ("digest", String(hash)),
        ("size", Number(bytes.length().to_double())),
        ("mode", Number(mode)),
      ]),
    )
  }
  files.sort_by(fn(a, b) {
    canonical(get(a, "path")).compare(canonical(get(b, "path")))
  })
  let snapshot = record([
    ("version", Number(1.0)),
    ("files", Array(files)),
    ("objects", Object(objects)),
    ("checksum", String(checksum(files))),
  ])
  ignore(manifest(snapshot))
  record([
    ("snapshot", snapshot),
    (
      "statistics",
      record([
        ("total_bytes", Number(bytes_total)),
        ("unique_bytes", Number(unique_bytes)),
        ("new_object_bytes", Number(new_bytes)),
        ("deduplicated_bytes", Number(bytes_total - unique_bytes)),
      ]),
    ),
    (
      "changes",
      if previous == Null {
        Null
      } else {
        diff_snapshots(previous, snapshot)
      },
    ),
  ])
}

///|
pub fn run(request : Value) -> Value raise {
  match optional_str(get(request, "operation"), "create") {
    "create" =>
      create_snapshot(
        arr(get(request, "files")),
        previous=get(request, "previous"),
      )
    "audit" => audit_snapshot(get(request, "snapshot"))
    "diff" => diff_snapshots(get(request, "old"), get(request, "new"))
    "restore" => {
      let snapshot = get(request, "snapshot")
      let report = audit_snapshot(snapshot)
      if get(report, "valid") != Bool(true) {
        raise InputError("cannot restore corrupted snapshot")
      }
      let objects = obj(get(snapshot, "objects"))
      let files = manifest(snapshot).map(fn(file) {
        let result = obj(file).copy()
        result["bytes"] = objects[str(get(file, "digest"))]
        Object(result)
      })
      record([("files", Array(files)), ("verified", Bool(true))])
    }
    _ => raise InputError("unknown snapshot operation")
  }
}