///| Git log helpers

///|
pub struct LogEntry {
  id : @bit.ObjectId
  message : String
  // Full commit message body (every line, trailing blank lines stripped); the
  // multi-line default log format prints these, while `message` is the subject.
  body_lines : Array[String]
  author : String
  timestamp : Int64
  // Author timezone offset (e.g. "+0900"), needed to render the "Date:" line.
  date_tz : String
  tree : @bit.ObjectId
  parent_tree : @bit.ObjectId?
}

///|
/// Read commit history from HEAD.
pub fn log_head(
  fs : &@bit.RepoFileSystem,
  git_dir : String,
  max_count? : Int = 100,
  since? : Int64 = 0L,
  until? : Int64 = 9223372036854775807L,
  include_parent_tree? : Bool = false,
) -> Array[LogEntry] raise @bit.GitError {
  let result : Array[LogEntry] = []
  let mut current = resolve_head_commit(fs, git_dir)
  let mut count = 0
  let object_git_dir = log_resolve_common_git_dir(fs, git_dir)
  let db = ObjectDb::load_lazy(fs, object_git_dir)
  while count < max_count {
    match current {
      None => break
      Some(id) => {
        let obj = db.get(fs, id)
        match obj {
          None => break
          Some(o) => {
            if o.obj_type != @bit.ObjectType::Commit {
              raise @bit.GitError::InvalidObject("Object is not a commit")
            }
            let info = @bit.parse_commit(o.data)
            let parents = log_resolve_parents(fs, git_dir, id, info.parents)
            let entry = parse_log_entry(id, o.data, info.tree)
            // Get parent tree for diff (only when needed for --stat/--patch)
            let parent_tree : @bit.ObjectId? = if include_parent_tree &&
              parents.length() > 0 {
              let parent_obj = db.get(fs, parents[0])
              match parent_obj {
                Some(po) => {
                  let parent_info = @bit.parse_commit(po.data)
                  Some(parent_info.tree)
                }
                None => None
              }
            } else {
              None
            }
            // Filter by since/until
            if entry.timestamp < since {
              // Older than since, stop traversal
              break
            }
            if entry.timestamp <= until {
              result.push({ ..entry, parent_tree, })
              count += 1
            }
            current = if parents.length() > 0 { Some(parents[0]) } else { None }
          }
        }
      }
    }
  }
  result
}

///|
/// Format like `git log --oneline`.
pub fn log_head_oneline(
  fs : &@bit.RepoFileSystem,
  git_dir : String,
  max_count? : Int = 100,
  since? : Int64 = 0L,
  until? : Int64 = 9223372036854775807L,
) -> Array[String] raise @bit.GitError {
  let entries = log_head(fs, git_dir, max_count~, since~, until~)
  let lines : Array[String] = []
  for e in entries {
    let short = @bithash.short_hex(e.id.to_hex(), 7)
    lines.push("\{short} \{e.message}")
  }
  lines
}

///|
fn parse_log_entry(
  id : @bit.ObjectId,
  data : Bytes,
  tree : @bit.ObjectId,
) -> LogEntry {
  let text = @utf8.decode_lossy(data[:])
  let lines = text.split("\n")
  let mut author = ""
  let mut timestamp = 0L
  let mut date_tz = "+0000"
  let mut in_message = false
  let message_lines : Array[String] = []
  for line_view in lines {
    let line = line_view.to_owned()
    if in_message {
      message_lines.push(line)
      continue
    }
    if line.length() == 0 {
      in_message = true
      continue
    }
    if line.has_prefix("author ") {
      let rest = String::unsafe_substring(line, start=7, end=line.length())
      let (name, time, tz) = parse_author_line(rest)
      author = name
      timestamp = time
      date_tz = tz
    }
  }
  let message = if message_lines.length() > 0 { message_lines[0] } else { "" }
  // Drop trailing blank lines (git strips them before printing the body).
  let mut body_end = message_lines.length()
  while body_end > 0 && message_lines[body_end - 1] == "" {
    body_end -= 1
  }
  let body_lines : Array[String] = []
  for i in 0.. (String, Int64, String) {
  // format: Name  1700000000 +0000
  let mut last_space = line.rev_find(" ")
  if last_space is None {
    return (line, 0L, "+0000")
  }
  let tz_idx = last_space.unwrap()
  let tz_str = String::unsafe_substring(
    line,
    start=tz_idx + 1,
    end=line.length(),
  )
  let tz = if tz_str.length() >= 4 &&
    (tz_str.has_prefix("+") || tz_str.has_prefix("-")) {
    tz_str
  } else {
    "+0000"
  }
  let before_tz = String::unsafe_substring(line, start=0, end=tz_idx)
  last_space = before_tz.rev_find(" ")
  if last_space is None {
    return (before_tz, 0L, tz)
  }
  let time_idx = last_space.unwrap()
  let name = String::unsafe_substring(before_tz, start=0, end=time_idx)
  let time_str = String::unsafe_substring(
    before_tz,
    start=time_idx + 1,
    end=before_tz.length(),
  )
  let ts = parse_int64(time_str)
  (name, ts, tz)
}

///|
fn parse_int64(s : String) -> Int64 {
  let mut result = 0L
  for c in s {
    if c < '0' || c > '9' {
      continue
    }
    let digit = c.to_int() - '0'.to_int()
    result = result * 10L + digit.to_int64()
  }
  result
}

///|
fn log_resolve_parents(
  fs : &@bit.RepoFileSystem,
  git_dir : String,
  commit_id : @bit.ObjectId,
  default_parents : Array[@bit.ObjectId],
) -> Array[@bit.ObjectId] {
  match log_read_graft_parents(fs, git_dir, commit_id) {
    Some(parents) => parents
    None => default_parents
  }
}

///|
fn log_read_graft_parents(
  fs : &@bit.RepoFileSystem,
  git_dir : String,
  commit_id : @bit.ObjectId,
) -> Array[@bit.ObjectId]? {
  let graft_path = join_path(
    log_resolve_common_git_dir(fs, git_dir),
    "info/grafts",
  )
  if !fs.is_file(graft_path) {
    return None
  }
  let content = decode_bytes_lossy(
    fs.read_file(graft_path) catch {
      _ => return None
    },
  )
  let commit_hex = commit_id.to_hex()
  for line_view in content.split("\n") {
    let line = @string_utils.trim_string(line_view.to_owned())
    if line.length() == 0 || line.has_prefix("#") {
      continue
    }
    let tokens : Array[String] = []
    for token_view in line.split(" ") {
      let token = @string_utils.trim_string(token_view.to_owned())
      if token.length() > 0 {
        tokens.push(token)
      }
    }
    if tokens.length() == 0 || tokens[0] != commit_hex {
      continue
    }
    let parents : Array[@bit.ObjectId] = []
    for i in 1.. continue
        }
        parents.push(parent_id)
      }
    }
    return Some(parents)
  }
  None
}

///|
fn log_resolve_common_git_dir(
  fs : &@bit.RepoFileSystem,
  git_dir : String,
) -> String {
  let commondir_path = join_path(git_dir, "commondir")
  if !fs.is_file(commondir_path) {
    return git_dir
  }
  let raw = @utf8.decode_lossy(
    (fs.read_file(commondir_path) catch { _ => Default::default() })[:],
  )
  let rel = @string_utils.trim_string(raw)
  if rel.length() == 0 {
    return git_dir
  }
  if rel.has_prefix("/") {
    normalize_path(rel)
  } else {
    normalize_path(join_path(git_dir, rel))
  }
}

///|