///| Pure repository object store and tree/commit parsing

///|
pub struct ObjectStore {
  objects : Map[String, @object.PackObject]
}

///|
pub fn ObjectStore::new() -> ObjectStore {
  { objects: Map([]) }
}

///|
pub fn ObjectStore::from_pack(
  objects : Array[@object.PackObject],
) -> ObjectStore {
  let store = ObjectStore::new()
  store.add_objects(objects)
  store
}

///|
pub fn ObjectStore::add_objects(
  self : ObjectStore,
  objects : Array[@object.PackObject],
) -> Unit {
  for obj in objects {
    // Use cached id if available (offset >= 0 means it was parsed from pack)
    let id = if obj.offset >= 0 {
      obj.id
    } else {
      @object.hash_object_content(obj.obj_type, obj.data)
    }
    self.objects[id.to_hex()] = obj
  }
}

///|
pub fn ObjectStore::get(
  self : ObjectStore,
  id : @object.ObjectId,
) -> @object.PackObject? {
  self.objects.get(id.to_hex())
}

///|
pub fn ObjectStore::contains(self : ObjectStore, id : @object.ObjectId) -> Bool {
  match self.objects.get(id.to_hex()) {
    Some(_) => true
    None => false
  }
}

///|
pub struct CommitInfo {
  tree : @object.ObjectId
  parents : Array[@object.ObjectId]
}

///|
pub fn parse_commit(content : Bytes) -> CommitInfo raise @object.GitError {
  let text = @utf8.decode_lossy(content[:])
  let mut tree_id : @object.ObjectId? = None
  let parents : Array[@object.ObjectId] = []
  for line_view in text.split("\n") {
    let line = line_view.to_owned()
    if line.length() == 0 {
      break
    }
    if line.has_prefix("tree ") {
      let hex = String::unsafe_substring(line, start=5, end=line.length())
      tree_id = hex |> @object.ObjectId::from_hex |> Some
    } else if line.has_prefix("parent ") {
      let hex = String::unsafe_substring(line, start=7, end=line.length())
      parents.push(@object.ObjectId::from_hex(hex))
    }
  }
  match tree_id {
    Some(tree) => { tree, parents }
    None => raise @object.GitError::InvalidObject("Missing tree in commit")
  }
}

///|
fn parse_tree_mode_is_octal(mode : String) -> Bool {
  if mode.length() == 0 {
    return false
  }
  for c in mode {
    if c < '0' || c > '7' {
      return false
    }
  }
  true
}

///|
fn parse_tree_with_hash_size(
  content : Bytes,
  hash_size : Int,
) -> Array[@object.TreeEntry] raise @object.GitError {
  let entries : Array[@object.TreeEntry] = []
  let mut i = 0
  while i < content.length() {
    // Parse mode (ASCII only, so byte-by-byte is fine)
    let mode_buf = StringBuilder::new()
    while i < content.length() && content[i] != b' ' {
      mode_buf.write_char(content[i].to_int().unsafe_to_char())
      i += 1
    }
    if i >= content.length() {
      raise @object.GitError::InvalidObject("Truncated tree entry mode")
    }
    let mode = mode_buf.to_string()
    if !parse_tree_mode_is_octal(mode) {
      raise @object.GitError::InvalidObject("Invalid tree entry mode")
    }
    i += 1 // skip space
    // Parse name - collect bytes and decode as UTF-8
    let name_start = i
    while i < content.length() && content[i] != b'\x00' {
      i += 1
    }
    if i >= content.length() {
      raise @object.GitError::InvalidObject("Truncated tree entry name")
    }
    let name_len = i - name_start
    let name_bytes = FixedArray::makei(name_len, fn(j) {
      content[name_start + j]
    })
    let name = @utf8.decode_lossy(Bytes::from_array(name_bytes)[:])
    i += 1 // skip NUL
    if i + hash_size > content.length() {
      raise @object.GitError::InvalidObject("Truncated tree entry id")
    }
    let id = @object.ObjectId::from_bytes_at(content, i, hash_size~)
    i += hash_size
    entries.push(@object.TreeEntry::new(mode, name, id))
  }
  entries
}

///|
pub fn parse_tree(
  content : Bytes,
) -> Array[@object.TreeEntry] raise @object.GitError {
  if content.length() == 0 {
    return []
  }
  parse_tree_with_hash_size(content, 20) catch {
    _ =>
      parse_tree_with_hash_size(content, 32) catch {
        _ => raise @object.GitError::InvalidObject("Invalid tree object")
      }
  }
}

///|
pub fn checkout_commit(
  store : ObjectStore,
  commit_id : @object.ObjectId,
) -> Map[String, Bytes] 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 files : Map[String, Bytes] = Map([])
      walk_tree(store, info.tree, "", files)
      files
    }
  }
}

///|
/// Materialize `commit_id` under `root`, producing an exact checkout: paths
/// tracked by a prior checkout that are absent from this commit are removed.
/// `rfs` must observe the same filesystem as `fs` (callers pass the same
/// concrete value as both trait views).
pub fn checkout_commit_to_fs(
  store : ObjectStore,
  commit_id : @object.ObjectId,
  fs : &@types.FileSystem,
  root : String,
  rfs : &@types.RepoFileSystem,
) -> Unit 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 files : Map[String, Bool] = Map([])
      let opaque_dirs : Map[String, Bool] = Map([])
      collect_tree_paths(store, info.tree, "", files, opaque_dirs)
      remove_stale_worktree_paths(rfs, fs, root, files, opaque_dirs)
      walk_tree_to_fs(store, info.tree, "", fs, root)
    }
  }
}

///|
/// Collect the set of blob paths (`files`) and gitlink/submodule mount
/// points (`opaque_dirs`) a tree will materialize to, without touching disk.
fn collect_tree_paths(
  store : ObjectStore,
  tree_id : @object.ObjectId,
  prefix : String,
  files : Map[String, Bool],
  opaque_dirs : Map[String, Bool],
) -> 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_paths(store, entry.id, path, files, opaque_dirs)
        } else if is_gitlink_mode(entry.mode) {
          opaque_dirs[path] = true
        } else {
          files[path] = true
        }
      }
    }
  }
}

///|
/// Remove worktree paths under `root` that are not part of the target tree
/// (`keep_files`), skipping `.git` and any gitlink/submodule mount point
/// (`opaque_dirs`) so submodule checkouts are left untouched. Returns
/// whether the directory ended up with nothing left in it.
fn remove_stale_worktree_paths(
  rfs : &@types.RepoFileSystem,
  fs : &@types.FileSystem,
  root : String,
  keep_files : Map[String, Bool],
  opaque_dirs : Map[String, Bool],
) -> Unit raise @object.GitError {
  ignore(remove_stale_dir(rfs, fs, root, "", keep_files, opaque_dirs))
}

///|
fn remove_stale_dir(
  rfs : &@types.RepoFileSystem,
  fs : &@types.FileSystem,
  root : String,
  rel_dir : String,
  keep_files : Map[String, Bool],
  opaque_dirs : Map[String, Bool],
) -> Bool raise @object.GitError {
  let full_dir = if rel_dir.length() == 0 {
    root
  } else {
    join_path(root, rel_dir)
  }
  if !rfs.is_dir(full_dir) {
    return true
  }
  let names = rfs.readdir(full_dir)
  let mut emptied = true
  for name in names {
    if rel_dir.length() == 0 && name == ".git" {
      emptied = false
      continue
    }
    let rel_path = if rel_dir.length() == 0 {
      name
    } else {
      rel_dir + "/" + name
    }
    if opaque_dirs.contains(rel_path) {
      emptied = false
      continue
    }
    let full_path = join_path(root, rel_path)
    if rfs.is_dir(full_path) {
      let child_emptied = remove_stale_dir(
        rfs, fs, root, rel_path, keep_files, opaque_dirs,
      )
      if child_emptied {
        fs.remove_dir(full_path)
      } else {
        emptied = false
      }
    } else if keep_files.contains(rel_path) {
      emptied = false
    } else {
      fs.remove_file(full_path)
    }
  }
  emptied
}

///|
fn walk_tree(
  store : ObjectStore,
  tree_id : @object.ObjectId,
  prefix : String,
  files : Map[String, Bytes],
) -> 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) {
          walk_tree(store, entry.id, path, files)
        } else if is_gitlink_mode(entry.mode) {
          // Skip gitlinks (submodules) when materializing file contents.
        } else {
          let blob_obj = store.get(entry.id)
          match blob_obj {
            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")
              }
              files[path] = b.data
            }
          }
        }
      }
    }
  }
}

///|
fn walk_tree_to_fs(
  store : ObjectStore,
  tree_id : @object.ObjectId,
  prefix : String,
  fs : &@types.FileSystem,
  root : String,
) -> 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) {
          walk_tree_to_fs(store, entry.id, path, fs, root)
        } else if is_gitlink_mode(entry.mode) {
          let full_path = join_path(root, path)
          fs.mkdir_p(full_path)
        } else {
          let blob_obj = store.get(entry.id)
          match blob_obj {
            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 full_path = join_path(root, path)
              let dir = parent_dir(full_path)
              fs.mkdir_p(dir)
              fs.write_file(full_path, b.data)
            }
          }
        }
      }
    }
  }
}

///|
pub fn is_tree_mode(mode : String) -> Bool {
  mode == "40000" || mode == "040000"
}

///|
pub fn is_gitlink_mode(mode : String) -> Bool {
  mode == "160000" || mode == "0160000"
}

///|
pub fn join_path(root : String, path : String) -> String {
  if root.length() == 0 || root == "/" {
    if path.has_prefix("/") {
      path
    } else {
      "/" + path
    }
  } else if root.has_suffix("/") {
    root + path
  } else {
    root + "/" + path
  }
}

///|
pub fn parent_dir(path : String) -> String {
  match path.rev_find("/") {
    None => "/"
    Some(0) => "/"
    Some(i) => String::unsafe_substring(path, start=0, end=i)
  }
}

///|
/// Verify a single tree-entry name is safe to materialize into a
/// worktree path.
///
/// Rejects: empty string, `.`, `..`, any name containing `/` or NUL,
/// and `.git` case-insensitively (the canonical foothold for hooks,
/// config, refs). Mirrors the subset of git's `verify_path()` that
/// matters for path-traversal during checkout / clone / fetch.
///
/// Called at the bytes→worktree boundary (`walk_tree_to_fs`,
/// `lib.collect_tree_files_inner`). Reading the bytes via
/// `@object.parse_tree` is unaffected: a repo might still contain
/// legacy commits with names we now reject, but bit refuses to
/// materialize them. Use `git fsck --strict` upstream to catch the
/// commit; bit will not silently write into `.git/` or above the
/// worktree.
pub fn verify_tree_entry_name(name : String) -> Unit raise @object.GitError {
  if name.length() == 0 {
    raise @object.GitError::InvalidObject("tree entry: empty name")
  }
  if name == "." || name == ".." {
    raise @object.GitError::InvalidObject("tree entry: name is '\{name}'")
  }
  for i in 0.. Int {
      if c >= 0x41 && c <= 0x5A {
        c + 32
      } else {
        c
      }
    }
    if c0 == 0x2E && // '.'
      lower(c1) == 0x67 && // 'g'
      lower(c2) == 0x69 && // 'i'
      lower(c3) == 0x74 { // 't'
      raise @object.GitError::InvalidObject("tree entry: name aliases '.git'")
    }
  }
}