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

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

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

///|
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_string()
    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 []
  }
  match
    (
      try? parse_tree_with_hash_size(content, 20),
      try? parse_tree_with_hash_size(content, 32),
    ) {
    (Ok(entries), Err(_)) => entries
    (Err(_), Ok(entries)) => entries
    (Ok(entries), Ok(_)) => entries
    (Err(_), Err(_)) =>
      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] = {}
      walk_tree(store, info.tree, "", files)
      files
    }
  }
}

///|
pub fn checkout_commit_to_fs(
  store : ObjectStore,
  commit_id : @object.ObjectId,
  fs : &@types.FileSystem,
  root : String,
) -> 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)
      walk_tree_to_fs(store, info.tree, "", fs, root)
    }
  }
}

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