///| Git repository materialization (.git + index)

///|
pub fn materialize_clone_to_fs(
  store : ObjectStore,
  commit_id : @object.ObjectId,
  refname : String,
  remote_url : String,
  fs : &@types.FileSystem,
  root : String,
  rfs : &@types.RepoFileSystem,
) -> Unit raise @object.GitError {
  checkout_commit_to_fs(store, commit_id, fs, root, rfs)
  write_git_metadata(store, commit_id, refname, remote_url, fs, root, rfs)
}

///|
pub fn write_git_metadata(
  store : ObjectStore,
  commit_id : @object.ObjectId,
  refname : String,
  remote_url : String,
  fs : &@types.FileSystem,
  root : String,
  rfs : &@types.RepoFileSystem,
) -> Unit raise @object.GitError {
  let git_dir = join_path(root, ".git")
  fs.mkdir_p(git_dir)
  fs.mkdir_p(join_path(git_dir, "objects"))
  fs.mkdir_p(join_path(git_dir, "objects/info"))
  fs.mkdir_p(join_path(git_dir, "objects/pack"))
  fs.mkdir_p(join_path(git_dir, "refs/heads"))
  fs.mkdir_p(join_path(git_dir, "refs/tags"))
  // Note: loose objects are not written here - pack file is used instead
  write_head_and_refs(fs, git_dir, refname, commit_id)
  write_config(fs, git_dir, remote_url)
  write_packed_refs(fs, git_dir)
  write_alternates(fs, git_dir)
  write_index_v2(store, fs, rfs, git_dir, commit_id, root)
}

///|
fn write_head_and_refs(
  fs : &@types.FileSystem,
  git_dir : String,
  refname : String,
  commit_id : @object.ObjectId,
) -> Unit raise @object.GitError {
  let head_path = join_path(git_dir, "HEAD")
  if refname == "HEAD" {
    write_text(fs, head_path, commit_id.to_hex() + "\n")
    return
  }
  write_text(fs, head_path, "ref: \{refname}\n")
  let ref_path = join_path(git_dir, refname)
  let dir = parent_dir(ref_path)
  fs.mkdir_p(dir)
  write_text(fs, ref_path, commit_id.to_hex() + "\n")
}

///|
fn write_config(
  fs : &@types.FileSystem,
  git_dir : String,
  remote_url : String,
) -> Unit raise @object.GitError {
  let config_path = join_path(git_dir, "config")
  write_text(fs, config_path, git_config_text(remote_url))
}

///|
fn git_config_text(remote_url : String) -> String {
  let base =
    #|[core]
    #|	repositoryformatversion = 0
    #|	filemode = true
    #|	bare = false
    #|	logallrefupdates = true
  let remote_block =
    $|[remote "origin"]
    $|	url = \{remote_url}
    $|	fetch = +refs/heads/*:refs/remotes/origin/*
    $|
  let config = if remote_url.length() == 0 {
    base + "\n"
  } else {
    base + "\n" + remote_block
  }
  config
}

///|
fn write_packed_refs(
  fs : &@types.FileSystem,
  git_dir : String,
) -> Unit raise @object.GitError {
  let path = join_path(git_dir, "packed-refs")
  write_text(fs, path, "# pack-refs with: peeled fully-peeled\n")
}

///|
fn write_alternates(
  fs : &@types.FileSystem,
  git_dir : String,
) -> Unit raise @object.GitError {
  let path = join_path(git_dir, "objects/info/alternates")
  write_text(fs, path, "")
}

///|
fn write_index_v2(
  store : ObjectStore,
  fs : &@types.FileSystem,
  rfs : &@types.RepoFileSystem,
  git_dir : String,
  commit_id : @object.ObjectId,
  root : String,
) -> Unit raise @object.GitError {
  let entries = collect_commit_entries(store, commit_id)
  // Git index entries must be in lexical path order.
  entries.sort_by((a, b) => String::lexical_compare(a.path, b.path))
  let entries_with_mtime = entries.map(entry => {
    let mtime = if entry.mode == 0o160000 {
      (0, 0)
    } else {
      rfs.mtime(join_path(root, entry.path))
    }
    (entry, mtime)
  })
  let index_path = join_path(git_dir, "index")
  fs.write_file(index_path, build_index_v2(entries_with_mtime))
}

///|
fn build_index_v2(entries : Array[(IndexEntryInfo, (Int, Int))]) -> Bytes {
  let out : Array[Byte] = []
  // Header: "DIRC" + version(2) + count
  out.push(b'D')
  out.push(b'I')
  out.push(b'R')
  out.push(b'C')
  push_u32_be(out, 2)
  push_u32_be(out, entries.length())
  for entry in entries {
    let (e, (mtime_sec, mtime_nsec)) = entry
    // Git index v2 entry format (62 bytes fixed + variable name):
    // ctime_sec (4) + ctime_nsec (4)
    push_u32_be(out, mtime_sec)
    push_u32_be(out, mtime_nsec)
    // mtime_sec (4) + mtime_nsec (4)
    push_u32_be(out, mtime_sec)
    push_u32_be(out, mtime_nsec)
    // dev (4) + ino (4)
    push_u32_be(out, 0)
    push_u32_be(out, 0)
    // mode (4) - comes BEFORE uid/gid
    push_u32_be(out, e.mode)
    // uid (4) + gid (4)
    push_u32_be(out, 0)
    push_u32_be(out, 0)
    // file_size (4)
    push_u32_be(out, e.size)
    // object id
    for b in e.id.bytes {
      out.push(b)
    }
    // Convert path to UTF-8 bytes
    let path_bytes = @utf8.encode(e.path)
    let name_byte_len = path_bytes.length()
    let name_len = if name_byte_len < 0x0fff { name_byte_len } else { 0x0fff }
    push_u16_be(out, name_len)
    for i in 0.. out[i]))
  let checksum = @object.sha1(content)
  for b in checksum.bytes {
    out.push(b)
  }
  Bytes::from_array(FixedArray::makei(out.length(), i => out[i]))
}

///|
priv struct IndexEntryInfo {
  path : String
  id : @object.ObjectId
  size : Int
  mode : Int
}

///|
fn collect_commit_entries(
  store : ObjectStore,
  commit_id : @object.ObjectId,
) -> Array[IndexEntryInfo] raise @object.GitError {
  let commit_obj = store.get(commit_id)
  match commit_obj {
    None => raise @object.GitError::InvalidObject("Missing commit object")
    Some(obj) => {
      if obj.obj_type != @object.ObjectType::Commit {
        raise @object.GitError::InvalidObject("Object is not a commit")
      }
      let info = parse_commit(obj.data)
      let out : Array[IndexEntryInfo] = []
      collect_tree_entries(store, info.tree, "", out)
      out
    }
  }
}

///|
fn collect_tree_entries(
  store : ObjectStore,
  tree_id : @object.ObjectId,
  prefix : String,
  out : Array[IndexEntryInfo],
) -> Unit raise @object.GitError {
  let tree_obj = store.get(tree_id)
  match tree_obj {
    None => raise @object.GitError::InvalidObject("Missing tree object")
    Some(obj) => {
      if obj.obj_type != @object.ObjectType::Tree {
        raise @object.GitError::InvalidObject("Object is not a tree")
      }
      let entries = parse_tree(obj.data)
      for entry in entries {
        verify_tree_entry_name(entry.name)
        let path = if prefix.length() == 0 {
          entry.name
        } else {
          prefix + "/" + entry.name
        }
        if is_tree_mode(entry.mode) {
          collect_tree_entries(store, entry.id, path, out)
        } else if is_gitlink_mode(entry.mode) {
          let mode = parse_octal(entry.mode)
          out.push({ path, id: entry.id, size: 0, mode })
        } else {
          let blob = store.get(entry.id)
          match blob {
            None => raise @object.GitError::InvalidObject("Missing blob object")
            Some(b) => {
              if b.obj_type != @object.ObjectType::Blob {
                raise @object.GitError::InvalidObject("Object is not a blob")
              }
              let mode = parse_octal(entry.mode)
              out.push({ path, id: entry.id, size: b.data.length(), mode })
            }
          }
        }
      }
    }
  }
}

///|
fn parse_octal(s : String) -> Int {
  let mut result = 0
  for c in s {
    if c < '0' || c > '7' {
      continue
    }
    result = result * 8 + (c.to_int() - '0'.to_int())
  }
  result
}

///|
fn push_u32_be(out : Array[Byte], v : Int) -> Unit {
  out.push(((v >> 24) & 0xff).to_byte())
  out.push(((v >> 16) & 0xff).to_byte())
  out.push(((v >> 8) & 0xff).to_byte())
  out.push((v & 0xff).to_byte())
}

///|
fn push_u16_be(out : Array[Byte], v : Int) -> Unit {
  out.push(((v >> 8) & 0xff).to_byte())
  out.push((v & 0xff).to_byte())
}

///|
fn write_text(
  fs : &@types.FileSystem,
  path : String,
  content : String,
) -> Unit raise @object.GitError {
  fs.write_string(path, content)
}

///|