///| Repository maintenance: repack + gc

///|
pub struct RepackResult {
  pack_id : @bit.ObjectId
  object_count : Int
  pack_bytes : Int
}

///|
pub struct GcResult {
  pack : RepackResult?
  dangling : Array[@bit.ObjectId]
}

///|
pub struct PruneResult {
  pruned : Array[@bit.ObjectId]
}

///|
/// Resolve the git dir that owns refs and objects for maintenance. Follows a
/// `.git` gitfile (linked worktree / submodule) and the worktree "commondir"
/// indirection so gc always sees the shared repository's refs; also accepts a
/// bare repository root.
fn gc_resolve_git_dir(fs : &@bit.RepoFileSystem, root : String) -> String {
  let marker = join_path(root, ".git")
  if !fs.is_dir(marker) &&
    !fs.is_file(marker) &&
    fs.is_dir(join_path(root, "objects")) &&
    fs.is_dir(join_path(root, "refs")) {
    return root
  }
  let git_dir = resolve_gitdir(fs, marker)
  match resolve_ref_commondir(fs, git_dir) {
    Some(common) => common
    None => git_dir
  }
}

///|
/// Repack reachable objects from refs under the repository root.
pub fn repack_repo(
  fs : &@bit.FileSystem,
  rfs : &@bit.RepoFileSystem,
  root : String,
) -> RepackResult? raise @bit.GitError {
  repack_git_dir(fs, rfs, gc_resolve_git_dir(rfs, root))
}

///|
/// Run a lightweight gc: repack reachable objects and report unreachable ids.
pub fn gc_repo(
  fs : &@bit.FileSystem,
  rfs : &@bit.RepoFileSystem,
  root : String,
) -> GcResult raise @bit.GitError {
  gc_git_dir(fs, rfs, gc_resolve_git_dir(rfs, root))
}

///|
/// Prune unreachable loose objects (skip if there are no refs).
pub fn prune_repo(
  fs : &@bit.FileSystem,
  rfs : &@bit.RepoFileSystem,
  root : String,
) -> PruneResult raise @bit.GitError {
  prune_git_dir(fs, rfs, gc_resolve_git_dir(rfs, root))
}

///|
fn repack_git_dir(
  fs : &@bit.FileSystem,
  rfs : &@bit.RepoFileSystem,
  git_dir : String,
) -> RepackResult? raise @bit.GitError {
  let ref_ids = collect_ref_ids(rfs, git_dir)
  if ref_ids.length() == 0 {
    return None
  }
  let db = ObjectDb::load(rfs, git_dir)
  let roots = resolve_commit_roots(db, rfs, ref_ids)
  if roots.length() == 0 {
    return None
  }
  let objects = collect_reachable_objects_from_commits(db, rfs, roots)
  collect_reachable_tag_objects(db, rfs, ref_ids, objects)
  // Reflog-referenced objects are roots too (git keeps them); repacking without
  // them would drop reflog-only objects from the new pack.
  collect_reflog_referenced_objects(db, rfs, git_dir, objects)
  repack_from_objects(fs, git_dir, objects)
}

///|
fn gc_git_dir(
  fs : &@bit.FileSystem,
  rfs : &@bit.RepoFileSystem,
  git_dir : String,
) -> GcResult raise @bit.GitError {
  let ref_ids = collect_ref_ids(rfs, git_dir)
  let db = ObjectDb::load(rfs, git_dir)
  let roots = resolve_commit_roots(db, rfs, ref_ids)
  let prune_enabled = roots.length() > 0
  let reachable = if roots.length() == 0 {
    []
  } else {
    collect_reachable_objects_from_commits(db, rfs, roots)
  }
  // Also include tag objects that refs point to directly (annotated tags).
  collect_reachable_tag_objects(db, rfs, ref_ids, reachable)
  // Also include objects referenced by reflogs (intermediate merge commits etc.)
  collect_reflog_referenced_objects(db, rfs, git_dir, reachable)
  let reachable_hex : Map[String, Bool] = Map([])
  for obj in reachable {
    let id = @bit.hash_object_content(obj.obj_type, obj.data)
    reachable_hex[id.to_hex()] = true
  }
  let store = load_object_store_from_fs(rfs, git_dir)
  let unreachable_hex : Array[String] = []
  for hex in store.objects.keys() {
    if !reachable_hex.contains(hex) {
      unreachable_hex.push(hex)
    }
  }
  unreachable_hex.sort()
  let dangling : Array[@bit.ObjectId] = []
  for hex in unreachable_hex {
    dangling.push(@bit.ObjectId::from_hex(hex))
  }
  let pack = repack_from_objects(fs, git_dir, reachable)
  if prune_enabled {
    let remove_reachable = match pack {
      Some(_) => true
      None => false
    }
    prune_loose_objects(fs, rfs, git_dir, reachable_hex, remove_reachable)
    match pack {
      Some(info) => prune_packfiles(fs, rfs, git_dir, info.pack_id)
      None => ()
    }
  }
  { pack, dangling }
}

///|
fn prune_git_dir(
  fs : &@bit.FileSystem,
  rfs : &@bit.RepoFileSystem,
  git_dir : String,
) -> PruneResult raise @bit.GitError {
  let ref_ids = collect_ref_ids(rfs, git_dir)
  let db = ObjectDb::load(rfs, git_dir)
  let roots = resolve_commit_roots(db, rfs, ref_ids)
  if roots.length() == 0 {
    return { pruned: [] }
  }
  let reachable = collect_reachable_objects_from_commits(db, rfs, roots)
  collect_reachable_tag_objects(db, rfs, ref_ids, reachable)
  // Match gc_git_dir: reflog-referenced objects are protected roots, so a bare
  // `bit gc --prune` must not delete reflog-only loose objects.
  collect_reflog_referenced_objects(db, rfs, git_dir, reachable)
  let reachable_hex : Map[String, Bool] = Map([])
  for obj in reachable {
    let id = @bit.hash_object_content(obj.obj_type, obj.data)
    reachable_hex[id.to_hex()] = true
  }
  let loose_hex = list_loose_object_hex(rfs, git_dir)
  let pruned : Array[@bit.ObjectId] = []
  for hex in loose_hex {
    if !reachable_hex.contains(hex) {
      pruned.push(@bit.ObjectId::from_hex(hex))
    }
  }
  prune_loose_objects(fs, rfs, git_dir, reachable_hex, false)
  { pruned, }
}

///|
fn repack_from_objects(
  fs : &@bit.FileSystem,
  git_dir : String,
  objects : Array[@bit.PackObject],
) -> RepackResult? raise @bit.GitError {
  if objects.length() == 0 {
    return None
  }
  let pack = @pack.create_packfile(objects)
  // Force index rebuild from the newly created pack to avoid stale offsets/CRC
  // carried from source packs.
  let objects_for_index : Array[@bit.PackObject] = []
  for obj in objects {
    objects_for_index.push(
      @bit.PackObject::with_metadata(obj.obj_type, obj.data, obj.id, -1, 0U),
    )
  }
  @pack.write_packfile_with_index(fs, git_dir, pack, objects_for_index)
  let pack_id = pack_trailer_id(pack)
  Some({ pack_id, object_count: objects.length(), pack_bytes: pack.length() })
}

///|
fn pack_trailer_id(pack : Bytes) -> @bit.ObjectId raise @bit.GitError {
  if pack.length() < 20 {
    raise @bit.GitError::PackfileError("Packfile too short")
  }
  let start = pack.length() - 20
  let bytes = FixedArray::makei(20, i => pack[start + i])
  @bit.ObjectId::new(bytes)
}

///|
fn collect_ref_ids(
  fs : &@bit.RepoFileSystem,
  git_dir : String,
) -> Array[@bit.ObjectId] raise @bit.GitError {
  let refs : Map[String, @bit.ObjectId] = Map([])
  // Every namespace under refs/ is a reachability root (heads, tags, remotes,
  // notes, agent, stash, replace, ...). Matching git, which roots ALL refs.
  let refs_dir = join_path(git_dir, "refs")
  if fs.is_dir(refs_dir) {
    collect_loose_refs(fs, refs_dir, "refs", refs)
  }
  let packed_path = join_path(git_dir, "packed-refs")
  if fs.is_file(packed_path) {
    collect_packed_refs(fs, packed_path, refs)
  }
  let (head, _) = list_branches(fs, git_dir)
  match head {
    Detached(id) => refs["HEAD"] = id
    Branch(_) => ()
  }
  collect_worktree_roots(fs, git_dir, refs)
  refs.values().to_array()
}

///|
/// Linked worktrees keep per-worktree state under worktrees// in the
/// common git dir. Both the (possibly detached) HEAD and the per-worktree refs
/// (refs/bisect/*, refs/worktree/*, refs/rewritten/*) are reachability roots
/// git honors, so collect them all.
fn collect_worktree_roots(
  fs : &@bit.RepoFileSystem,
  git_dir : String,
  out : Map[String, @bit.ObjectId],
) -> Unit raise @bit.GitError {
  let worktrees_dir = join_path(git_dir, "worktrees")
  if !fs.is_dir(worktrees_dir) {
    return
  }
  for name in fs.readdir(worktrees_dir) {
    let wt_dir = join_path(worktrees_dir, name)
    collect_worktree_head(fs, wt_dir, name, out)
    let wt_refs = join_path(wt_dir, "refs")
    if fs.is_dir(wt_refs) {
      collect_loose_refs(fs, wt_refs, "worktrees/" + name + "/refs", out)
    }
  }
}

///|
/// A detached worktree HEAD may be the only root keeping its checkout alive.
fn collect_worktree_head(
  fs : &@bit.RepoFileSystem,
  wt_dir : String,
  name : String,
  out : Map[String, @bit.ObjectId],
) -> Unit {
  let head_path = join_path(wt_dir, "HEAD")
  if !fs.is_file(head_path) {
    return
  }
  let line = read_ref_line_gc(fs, head_path) catch { _ => return }
  if line.has_prefix("ref: ") {
    return
  }
  let id = @bit.ObjectId::from_hex(line) catch { _ => return }
  out["worktrees/" + name + "/HEAD"] = id
}

///|
fn collect_loose_refs(
  fs : &@bit.RepoFileSystem,
  dir : String,
  prefix : String,
  out : Map[String, @bit.ObjectId],
) -> Unit raise @bit.GitError {
  let entries = fs.readdir(dir)
  for name in entries {
    let path = join_path(dir, name)
    let refname = if prefix == "" { name } else { prefix + "/" + name }
    if fs.is_dir(path) {
      collect_loose_refs(fs, path, refname, out)
    } else if fs.is_file(path) {
      // Skip symrefs (e.g. refs/remotes/origin/HEAD) — their targets are
      // walked directly — and tolerate unparseable content instead of
      // aborting the whole collection.
      let line = read_ref_line_gc(fs, path) catch { _ => continue }
      if line.has_prefix("ref: ") {
        continue
      }
      let id = @bit.ObjectId::from_hex(line) catch { _ => continue }
      out[refname] = id
    }
  }
}

///|
fn collect_packed_refs(
  fs : &@bit.RepoFileSystem,
  packed_path : String,
  out : Map[String, @bit.ObjectId],
) -> Unit raise @bit.GitError {
  let text = @utf8.decode_lossy(fs.read_file(packed_path)[:])
  for line_view in text.split("\n") {
    let line = trim_line_gc(line_view.to_owned())
    if line.length() == 0 {
      continue
    }
    if line.has_prefix("#") || line.has_prefix("^") {
      continue
    }
    let space = line.find(" ")
    match space {
      None => ()
      Some(idx) => {
        if idx + 1 >= line.length() {
          continue
        }
        let id_hex = String::unsafe_substring(line, start=0, end=idx)
        let refname = String::unsafe_substring(
          line,
          start=idx + 1,
          end=line.length(),
        )
        // Loose refs win over stale packed entries; accept every refs/
        // namespace as a root.
        if refname.has_prefix("refs/") && !out.contains(refname) {
          let id = @bit.ObjectId::from_hex(id_hex) catch { _ => continue }
          out[refname] = id
        }
      }
    }
  }
}

///|
fn read_ref_line_gc(
  fs : &@bit.RepoFileSystem,
  path : String,
) -> String raise @bit.GitError {
  let text = @utf8.decode_lossy(fs.read_file(path)[:])
  for line_view in text.split("\n") {
    let line = trim_line_gc(line_view.to_owned())
    if line.length() > 0 {
      return line
    }
  }
  raise @bit.GitError::InvalidObject("Empty ref: \{path}")
}

///|
fn trim_line_gc(line : String) -> String {
  let mut s = line
  if s.has_suffix("\r") {
    s = String::unsafe_substring(s, start=0, end=s.length() - 1)
  }
  s
}

///|
fn collect_reflog_referenced_objects(
  db : ObjectDb,
  fs : &@bit.RepoFileSystem,
  git_dir : String,
  objects : Array[@bit.PackObject],
) -> Unit raise @bit.GitError {
  let seen : Map[String, Bool] = Map([])
  for obj in objects {
    seen[@bit.hash_object_content(obj.obj_type, obj.data).to_hex()] = true
  }
  // Walk all reflog files and collect referenced OIDs
  let reflog_ids = collect_all_reflog_ids(fs, git_dir)
  for id in reflog_ids {
    let hex = id.to_hex()
    if seen.contains(hex) || id.is_zero() {
      continue
    }
    match db.get(fs, id) {
      Some(obj) => {
        seen[hex] = true
        objects.push(obj)
        // If it's a commit, also collect its tree and blobs
        if obj.obj_type == @bit.ObjectType::Commit {
          let sub = collect_reachable_objects_from_commits(db, fs, [id])
          for s in sub {
            let sh = @bit.hash_object_content(s.obj_type, s.data).to_hex()
            if !seen.contains(sh) {
              seen[sh] = true
              objects.push(s)
            }
          }
        }
      }
      None => ()
    }
  }
}

///|
fn collect_all_reflog_ids(
  fs : &@bit.RepoFileSystem,
  git_dir : String,
) -> Array[@bit.ObjectId] raise @bit.GitError {
  let ids : Array[@bit.ObjectId] = []
  let logs_dir = git_dir + "/logs"
  if fs.is_dir(logs_dir) {
    collect_reflog_ids_recursive(fs, logs_dir, ids)
  }
  // Linked worktrees keep their own reflogs under worktrees//logs.
  let worktrees_dir = git_dir + "/worktrees"
  if fs.is_dir(worktrees_dir) {
    for name in fs.readdir(worktrees_dir) {
      let wt_logs = worktrees_dir + "/" + name + "/logs"
      if fs.is_dir(wt_logs) {
        collect_reflog_ids_recursive(fs, wt_logs, ids)
      }
    }
  }
  ids
}

///|
fn collect_reflog_ids_recursive(
  fs : &@bit.RepoFileSystem,
  dir : String,
  ids : Array[@bit.ObjectId],
) -> Unit raise @bit.GitError {
  let entries = fs.readdir(dir) catch { _ => return }
  for name in entries {
    let path = dir + "/" + name
    if fs.is_dir(path) {
      collect_reflog_ids_recursive(fs, path, ids)
    } else if fs.is_file(path) {
      let content = @utf8.decode_lossy(
        (fs.read_file(path) catch { _ => continue })[:],
      ).to_string()
      for line_view in content.split("\n") {
        let line = line_view.to_owned()
        if line.length() < 82 {
          continue
        }
        // Format: old_hex new_hex ...
        let old_hex = String::unsafe_substring(line, start=0, end=40)
        let new_hex = String::unsafe_substring(line, start=41, end=81)
        if @bithash.is_hex_string(old_hex) {
          ids.push(@bit.ObjectId::from_hex(old_hex))
        }
        if @bithash.is_hex_string(new_hex) {
          ids.push(@bit.ObjectId::from_hex(new_hex))
        }
      }
    }
  }
}

///|
fn collect_reachable_tag_objects(
  db : ObjectDb,
  fs : &@bit.RepoFileSystem,
  ref_ids : Array[@bit.ObjectId],
  objects : Array[@bit.PackObject],
) -> Unit raise @bit.GitError {
  let seen : Map[String, Bool] = Map([])
  for obj in objects {
    seen[@bit.hash_object_content(obj.obj_type, obj.data).to_hex()] = true
  }
  for id in ref_ids {
    let hex = id.to_hex()
    if seen.contains(hex) {
      continue
    }
    match db.get(fs, id) {
      Some(obj) if obj.obj_type == @bit.ObjectType::Tag => {
        seen[hex] = true
        objects.push(obj)
      }
      _ => ()
    }
  }
}

///|
fn resolve_commit_roots(
  db : ObjectDb,
  fs : &@bit.RepoFileSystem,
  ref_ids : Array[@bit.ObjectId],
) -> Array[@bit.ObjectId] raise @bit.GitError {
  let seen : Map[String, Bool] = Map([])
  let roots : Array[@bit.ObjectId] = []
  for id in ref_ids {
    let local_seen : Map[String, Bool] = Map([])
    match resolve_commit_from_ref(db, fs, id, local_seen) {
      None => ()
      Some(commit_id) => {
        let hex = commit_id.to_hex()
        if !seen.contains(hex) {
          seen[hex] = true
          roots.push(commit_id)
        }
      }
    }
  }
  roots
}

///|
fn resolve_commit_from_ref(
  db : ObjectDb,
  fs : &@bit.RepoFileSystem,
  id : @bit.ObjectId,
  seen : Map[String, Bool],
) -> @bit.ObjectId? raise @bit.GitError {
  let hex = id.to_hex()
  if seen.contains(hex) {
    return None
  }
  seen[hex] = true
  match db.get(fs, id) {
    None => None
    Some(obj) =>
      match obj.obj_type {
        @bit.ObjectType::Commit => Some(id)
        @bit.ObjectType::Tag =>
          match parse_tag_target(obj.data) {
            None => None
            Some((target, tag_type)) =>
              if tag_type == "commit" {
                Some(target)
              } else if tag_type == "tag" {
                resolve_commit_from_ref(db, fs, target, seen)
              } else {
                None
              }
          }
        _ => None
      }
  }
}

///|
fn parse_tag_target(
  data : Bytes,
) -> (@bit.ObjectId, String)? raise @bit.GitError {
  let text = @utf8.decode_lossy(data[:])
  let mut object_hex : String? = None
  let mut object_type : String? = None
  for line_view in text.split("\n") {
    let line = line_view.to_owned()
    if line.length() == 0 {
      break
    }
    if line.has_prefix("object ") {
      let hex = String::unsafe_substring(line, start=7, end=line.length())
      object_hex = Some(hex)
    } else if line.has_prefix("type ") {
      let typ = String::unsafe_substring(line, start=5, end=line.length())
      object_type = Some(typ)
    }
  }
  match object_hex {
    None => None
    Some(hex) =>
      match object_type {
        None => None
        Some(typ) => Some((@bit.ObjectId::from_hex(hex), typ))
      }
  }
}

///|
fn prune_loose_objects(
  fs : &@bit.FileSystem,
  rfs : &@bit.RepoFileSystem,
  git_dir : String,
  keep : Map[String, Bool],
  remove_reachable : Bool,
) -> Unit raise @bit.GitError {
  let objects_dir = join_path(git_dir, "objects")
  if !rfs.is_dir(objects_dir) {
    return
  }
  let entries = rfs.readdir(objects_dir)
  for entry in entries {
    if entry == "info" || entry == "pack" {
      continue
    }
    if entry.length() != 2 {
      continue
    }
    let dir = join_path(objects_dir, entry)
    if !rfs.is_dir(dir) {
      continue
    }
    let files = rfs.readdir(dir)
    for name in files {
      if name.length() != 38 {
        continue
      }
      let path = join_path(dir, name)
      if !rfs.is_file(path) {
        continue
      }
      let hex = entry + name
      if !remove_reachable && keep.contains(hex) {
        continue
      }
      fs.remove_file(path)
    }
  }
}

///|
fn list_loose_object_hex(
  fs : &@bit.RepoFileSystem,
  git_dir : String,
) -> Array[String] raise @bit.GitError {
  let out : Array[String] = []
  let objects_dir = join_path(git_dir, "objects")
  if !fs.is_dir(objects_dir) {
    return out
  }
  let entries = fs.readdir(objects_dir)
  for entry in entries {
    if entry == "info" || entry == "pack" {
      continue
    }
    if entry.length() != 2 {
      continue
    }
    let dir = join_path(objects_dir, entry)
    if !fs.is_dir(dir) {
      continue
    }
    let files = fs.readdir(dir)
    for name in files {
      if name.length() != 38 {
        continue
      }
      let path = join_path(dir, name)
      if !fs.is_file(path) {
        continue
      }
      out.push(entry + name)
    }
  }
  out
}

///|
fn prune_packfiles(
  fs : &@bit.FileSystem,
  rfs : &@bit.RepoFileSystem,
  git_dir : String,
  keep_pack_id : @bit.ObjectId,
) -> Unit raise @bit.GitError {
  let pack_dir = join_path(git_dir, "objects/pack")
  if !rfs.is_dir(pack_dir) {
    return
  }
  let base = "pack-" + keep_pack_id.to_hex()
  let keep_pack = base + ".pack"
  let keep_idx = base + ".idx"
  let entries = rfs.readdir(pack_dir)
  for entry in entries {
    if entry == keep_pack || entry == keep_idx {
      continue
    }
    if entry.has_suffix(".pack") || entry.has_suffix(".idx") {
      fs.remove_file(join_path(pack_dir, entry))
    }
  }
}