///| Git diff helpers (extracted package)

///|
pub(all) enum DiffKind {
  Added
  Modified
  Deleted
}

///|
pub(all) struct DiffFile {
  path : String
  kind : DiffKind
  old_content : Bytes?
  new_content : Bytes?
  old_mode : Int?
  new_mode : Int?
}

///|
/// Diff working tree against index (default `git diff`).
pub async fn diff_worktree(
  fs : &@bit.RepoFileSystem,
  root : String,
) -> Array[DiffFile] raise @bit.GitError {
  let profile = profile_enabled()
  let mut t0 = profile_start(profile)
  let git_marker = @bit.join_path(root, ".git")
  let git_dir = @bitlib.resolve_gitdir(fs, git_marker)
  let common_git_dir = @bitlib.resolve_common_git_dir(fs, git_dir)
  let autocrlf = read_autocrlf_setting(fs, common_git_dir)
  let index_entries = @bitlib.read_index_entries(fs, git_dir)
  let skip_worktree_paths = @bitlib.read_skip_worktree_paths(fs, git_dir)
  t0 = profile_lap(profile, "diff_worktree read_index_entries", t0)
  let db = @bitlib.ObjectDb::load_lazy(fs, git_dir)
  t0 = profile_lap(profile, "diff_worktree load_db", t0)
  let cached_files = @bitlib.working_files_cache(root)
  let cache_map : Map[String, Bool] = Map([])
  let mut cache_ready = false
  match cached_files {
    Some(files) => {
      cache_ready = true
      for p in files {
        cache_map[p] = true
      }
    }
    None => ()
  }
  let out : Array[DiffFile] = []
  for entry in index_entries {
    let path = entry.path
    if is_gitlink_mode_int(entry.mode) {
      let sub_diff = diff_worktree_gitlink_change(fs, root, entry)
      match sub_diff {
        Some(file) => out.push(file)
        None => ()
      }
      continue
    }
    if skip_worktree_paths.contains(path) {
      continue
    }
    if cache_ready && !cache_map.contains(path) {
      let ignored = @bitlib.is_ignored_path(fs, root, path, false)
      if !ignored {
        let old = diff_read_blob(db, fs, entry.id)
        // Skip if blob not found (e.g., submodule)
        if old is Some(_) {
          out.push({
            path,
            kind: DiffKind::Deleted,
            old_content: old,
            new_content: None,
            old_mode: Some(entry.mode),
            new_mode: None,
          })
        }
        continue
      }
    }
    let abs = @bit.join_path(root, path)
    match @bitio.worktree_entry_meta(fs, abs) {
      None => {
        let old = diff_read_blob(db, fs, entry.id)
        // Skip if blob not found (e.g., submodule)
        if old is Some(_) {
          out.push({
            path,
            kind: DiffKind::Deleted,
            old_content: old,
            new_content: None,
            old_mode: Some(entry.mode),
            new_mode: None,
          })
        }
      }
      Some(info) => {
        let mut content_changed = false
        let mode_changed = info.mode != entry.mode
        let mut old_content : Bytes? = None
        let mut new_content : Bytes? = None
        match info.kind {
          @bitio.WorktreeKindMeta::Regular => {
            let mut stat_matches = false
            match info.size {
              Some(sz) =>
                match info.mtime_sec {
                  Some(ms) =>
                    match info.mtime_nsec {
                      Some(mn) =>
                        if entry.mtime_sec != 0 || entry.mtime_nsec != 0 {
                          stat_matches = sz == entry.size &&
                            ms == entry.mtime_sec &&
                            mn == entry.mtime_nsec
                        }
                      None => ()
                    }
                  None => ()
                }
              None => ()
            }
            if !mode_changed && stat_matches {
              content_changed = false
            } else {
              let content = fs.read_file(abs)
              let normalized = normalize_worktree_content(content, autocrlf)
              let id = @bit.hash_blob(normalized)
              content_changed = id != entry.id
              if content_changed {
                let old = diff_read_blob(db, fs, entry.id)
                old_content = old
                new_content = Some(normalized)
              }
            }
          }
          @bitio.WorktreeKindMeta::Symlink => {
            let mut stat_matches = false
            match info.mtime_sec {
              Some(ms) =>
                match info.mtime_nsec {
                  Some(mn) =>
                    if entry.mtime_sec != 0 || entry.mtime_nsec != 0 {
                      stat_matches = ms == entry.mtime_sec &&
                        mn == entry.mtime_nsec
                    }
                  None => ()
                }
              None => ()
            }
            if !mode_changed && stat_matches {
              content_changed = false
            } else {
              match @bitio.read_symlink_target_path(abs) {
                Some(target) => {
                  let bytes = @utf8.encode(target)
                  let id = @bit.hash_blob(bytes)
                  content_changed = id != entry.id
                  if content_changed {
                    let old = diff_read_blob(db, fs, entry.id)
                    old_content = old
                    new_content = Some(bytes)
                  }
                }
                None => ()
              }
            }
          }
        }
        if content_changed && old_content is None {
          if diff_is_zero_oid(entry.id) && new_content is Some(nc) {
            out.push({
              path,
              kind: DiffKind::Added,
              old_content: None,
              new_content: Some(nc),
              old_mode: None,
              new_mode: Some(info.mode),
            })
          }
          continue
        }
        if content_changed || mode_changed {
          out.push({
            path,
            kind: DiffKind::Modified,
            old_content,
            new_content,
            old_mode: Some(entry.mode),
            new_mode: Some(info.mode),
          })
        }
      }
    }
  }
  t0 = profile_lap(profile, "diff_worktree scan_entries", t0)
  out.sort_by((a, b) => String::compare(a.path, b.path))
  ignore(profile_lap(profile, "diff_worktree sort", t0))
  out
}

///|
fn diff_gitlink_content(id : @bit.ObjectId) -> Bytes {
  @utf8.encode("Subproject commit \{id.to_hex()}\n"[:])
}

///|
fn diff_gitlink_content_oid(content : Bytes?) -> String? {
  match content {
    Some(data) => {
      let text = @utf8.decode_lossy(data[:])
      let prefix = "Subproject commit "
      guard text.has_prefix(prefix) else { return None }
      let mut oid = String::unsafe_substring(
        text,
        start=prefix.length(),
        end=text.length(),
      )
      if oid.has_suffix("\n") {
        oid = String::unsafe_substring(oid, start=0, end=oid.length() - 1)
      }
      if oid.length() == 40 {
        Some(oid)
      } else {
        None
      }
    }
    None => None
  }
}

///|
fn diff_content_for_entry(
  db : @bitlib.ObjectDb,
  fs : &@bit.RepoFileSystem,
  id : @bit.ObjectId,
  mode : Int,
) -> Bytes? raise @bit.GitError {
  if is_gitlink_mode_int(mode) {
    Some(diff_gitlink_content(id))
  } else {
    diff_read_blob(db, fs, id)
  }
}

///|
fn diff_worktree_gitlink_change(
  fs : &@bit.RepoFileSystem,
  root : String,
  entry : @bitlib.IndexEntry,
) -> DiffFile? {
  let sub_root = @bit.join_path(root, entry.path)
  if !fs.is_dir(sub_root) {
    return None
  }
  let sub_git = @bit.join_path(sub_root, ".git")
  if !fs.is_file(sub_git) && !fs.is_dir(sub_git) {
    return None
  }
  let sub_git_dir = @bitlib.resolve_gitdir(fs, sub_git)
  let head_id = @bitlib.resolve_head_commit(fs, sub_git_dir) catch { _ => None }
  match head_id {
    Some(id) =>
      if id != entry.id {
        Some({
          path: entry.path,
          kind: DiffKind::Modified,
          old_content: Some(diff_gitlink_content(entry.id)),
          new_content: Some(diff_gitlink_content(id)),
          old_mode: Some(entry.mode),
          new_mode: Some(entry.mode),
        })
      } else {
        None
      }
    None => None
  }
}

///|
/// Diff index against HEAD (`git diff --cached`).
pub fn diff_index(
  fs : &@bit.RepoFileSystem,
  root : String,
) -> Array[DiffFile] raise @bit.GitError {
  let git_marker = @bit.join_path(root, ".git")
  let git_dir = @bitlib.resolve_gitdir(fs, git_marker)
  let index_entries = @bitlib.read_index_entries(fs, git_dir)
  let index_map : Map[String, @bitlib.IndexEntry] = Map([])
  for e in index_entries {
    index_map[e.path] = e
  }
  let head_entries = diff_read_head_entries(fs, git_dir)
  let db = @bitlib.ObjectDb::load_lazy(fs, git_dir)
  let out : Array[DiffFile] = []
  for path, entry in index_map {
    match head_entries.get(path) {
      None => {
        let new = diff_content_for_entry(db, fs, entry.id, entry.mode)
        // Skip if blob not found (e.g., submodule)
        if new is Some(_) {
          out.push({
            path,
            kind: DiffKind::Added,
            old_content: None,
            new_content: new,
            old_mode: None,
            new_mode: Some(entry.mode),
          })
        }
      }
      Some(h) =>
        if h.id != entry.id || h.mode != entry.mode {
          let content_changed = h.id != entry.id
          let mut old : Bytes? = None
          let mut new : Bytes? = None
          if content_changed {
            old = diff_content_for_entry(db, fs, h.id, h.mode)
            new = diff_content_for_entry(db, fs, entry.id, entry.mode)
            // Skip if either blob not found (e.g., submodule)
            if old is None || new is None {
              continue
            }
          }
          out.push({
            path,
            kind: DiffKind::Modified,
            old_content: old,
            new_content: new,
            old_mode: Some(h.mode),
            new_mode: Some(entry.mode),
          })
        }
    }
  }
  for path, h in head_entries {
    if !index_map.contains(path) {
      let old = diff_content_for_entry(db, fs, h.id, h.mode)
      // Skip if blob not found (e.g., submodule)
      if old is Some(_) {
        out.push({
          path,
          kind: DiffKind::Deleted,
          old_content: old,
          new_content: None,
          old_mode: Some(h.mode),
          new_mode: None,
        })
      }
    }
  }
  out.sort_by((a, b) => String::compare(a.path, b.path))
  out
}

///|
/// Format diff as unified text.
pub fn diff_text(
  files : Array[DiffFile],
  context_lines? : Int = 3,
) -> Array[String] {
  let lines : Array[String] = []
  for f in files {
    lines.push("diff --git a/\{f.path} b/\{f.path}")
    diff_emit_modes(lines, f)
    match f.kind {
      DiffKind::Added => {
        diff_emit_index_line(lines, None, f.new_content, None)
        lines.push("--- /dev/null")
        lines.push("+++ b/\{f.path}")
        diff_emit_unified_hunks(lines, None, f.new_content, context_lines)
      }
      DiffKind::Deleted => {
        diff_emit_index_line(lines, f.old_content, None, None)
        lines.push("--- a/\{f.path}")
        lines.push("+++ /dev/null")
        diff_emit_unified_hunks(lines, f.old_content, None, context_lines)
      }
      DiffKind::Modified => {
        let mode = match (f.old_mode, f.new_mode) {
          (Some(old), Some(new)) if old == new => Some(new)
          _ => None
        }
        diff_emit_index_line(lines, f.old_content, f.new_content, mode)
        lines.push("--- a/\{f.path}")
        lines.push("+++ b/\{f.path}")
        diff_emit_unified_hunks(
          lines,
          f.old_content,
          f.new_content,
          context_lines,
        )
      }
    }
  }
  lines
}

///|
fn diff_blob_short_id(data : Bytes) -> String {
  let hex = diff_gitlink_content_oid(Some(data)).unwrap_or_else(fn() {
    @bit.hash_blob(data).to_hex()
  })
  String::unsafe_substring(hex, start=0, end=7)
}

///|
fn diff_is_zero_oid(id : @bit.ObjectId) -> Bool {
  id.is_zero()
}

///|
fn diff_emit_index_line(
  out : Array[String],
  old_content : Bytes?,
  new_content : Bytes?,
  mode : Int?,
) -> Unit {
  let old_short = match old_content {
    Some(data) => diff_blob_short_id(data)
    None => "0000000"
  }
  let new_short = match new_content {
    Some(data) => diff_blob_short_id(data)
    None => "0000000"
  }
  if old_short == "0000000" && new_short == "0000000" {
    return
  }
  let mut line = "index \{old_short}..\{new_short}"
  match mode {
    Some(file_mode) => line += " " + mode_to_string(file_mode)
    None => ()
  }
  out.push(line)
}

///|

///|
fn diff_emit_unified_hunks(
  out : Array[String],
  old_content : Bytes?,
  new_content : Bytes?,
  context_lines : Int,
) -> Unit {
  @diff_core.unified_hunks(out, old_content, new_content, context_lines~)
}

///|
fn diff_emit_modes(out : Array[String], f : DiffFile) -> Unit {
  match f.kind {
    DiffKind::Added =>
      match f.new_mode {
        Some(mode) => out.push("new file mode " + mode_to_string(mode))
        None => ()
      }
    DiffKind::Deleted =>
      match f.old_mode {
        Some(mode) => out.push("deleted file mode " + mode_to_string(mode))
        None => ()
      }
    DiffKind::Modified =>
      match (f.old_mode, f.new_mode) {
        (Some(old), Some(new)) if old != new => {
          out.push("old mode " + mode_to_string(old))
          out.push("new mode " + mode_to_string(new))
        }
        _ => ()
      }
  }
}

///|
/// Format diff as stat summary (like git diff --stat).
pub fn diff_stat(files : Array[DiffFile]) -> Array[String] {
  let lines : Array[String] = []
  let mut total_add = 0
  let mut total_del = 0
  let mut max_name_len = 0
  for f in files {
    if f.path.length() > max_name_len {
      max_name_len = f.path.length()
    }
  }
  for f in files {
    let (add, del) = count_line_changes(f.old_content, f.new_content)
    total_add = total_add + add
    total_del = total_del + del
    let total = add + del
    let bar_len = if total > 50 { 50 } else { total }
    let add_bar = "+".repeat(if total > 0 { add * bar_len / total } else { 0 })
    let del_bar = "-".repeat(if total > 0 { del * bar_len / total } else { 0 })
    let padding = " ".repeat(max_name_len - f.path.length())
    lines.push(" \{f.path}\{padding} | \{total} \{add_bar}\{del_bar}")
  }
  if files.length() > 0 {
    let files_changed = files.length()
    lines.push(
      " \{files_changed} file(s) changed, \{total_add} insertions(+), \{total_del} deletions(-)",
    )
  }
  lines
}

///|
fn count_line_changes(old : Bytes?, new : Bytes?) -> (Int, Int) {
  let old_lines = match old {
    Some(data) => count_lines(data)
    None => 0
  }
  let new_lines = match new {
    Some(data) => count_lines(data)
    None => 0
  }
  if new_lines >= old_lines {
    (new_lines - old_lines, 0)
  } else {
    (0, old_lines - new_lines)
  }
}

///|
fn count_lines(data : Bytes) -> Int {
  @diff_core.count_lines(data)
}

///|
fn diff_read_blob(
  db : @bitlib.ObjectDb,
  fs : &@bit.RepoFileSystem,
  id : @bit.ObjectId,
) -> Bytes? raise @bit.GitError {
  let obj = db.get(fs, id)
  match obj {
    None => None // Object might be in a submodule or unavailable
    Some(o) =>
      if o.obj_type != @bit.ObjectType::Blob {
        None // Submodule entries point to commits, not blobs
      } else {
        Some(o.data)
      }
  }
}

///|
priv struct DiffHeadEntry {
  id : @bit.ObjectId
  mode : Int
}

///|
fn diff_read_head_entries(
  fs : &@bit.RepoFileSystem,
  git_dir : String,
) -> Map[String, DiffHeadEntry] raise @bit.GitError {
  let result : Map[String, DiffHeadEntry] = Map([])
  let head = @bitlib.resolve_head_commit(fs, git_dir)
  match head {
    None => return result
    Some(commit_id) => {
      let db = @bitlib.ObjectDb::load(fs, git_dir)
      let commit_obj = db.get(fs, commit_id)
      match commit_obj {
        None => return result
        Some(obj) => {
          if obj.obj_type != @bit.ObjectType::Commit {
            raise @bit.GitError::InvalidObject("Object is not a commit")
          }
          let info = @bit.parse_commit(obj.data)
          diff_collect_tree(db, fs, info.tree, "", result)
        }
      }
    }
  }
  result
}

///|
fn diff_collect_tree(
  db : @bitlib.ObjectDb,
  fs : &@bit.RepoFileSystem,
  tree_id : @bit.ObjectId,
  prefix : String,
  out : Map[String, DiffHeadEntry],
) -> Unit raise @bit.GitError {
  let tree_obj = db.get(fs, tree_id)
  match tree_obj {
    None => raise @bit.GitError::InvalidObject("Missing tree object")
    Some(obj) => {
      if obj.obj_type != @bit.ObjectType::Tree {
        raise @bit.GitError::InvalidObject("Object is not a tree")
      }
      let entries = @bit.parse_tree(obj.data)
      for entry in entries {
        let path = if prefix.length() == 0 {
          entry.name
        } else {
          prefix + "/" + entry.name
        }
        if diff_is_tree_mode(entry.mode) {
          diff_collect_tree(db, fs, entry.id, path, out)
        } else {
          out[path] = { id: entry.id, mode: worktree_parse_octal(entry.mode) }
        }
      }
    }
  }
}

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

///|
/// Diff two trees by their ObjectIds. Returns DiffFile array.
pub fn diff_trees(
  fs : &@bit.RepoFileSystem,
  git_dir : String,
  old_tree : @bit.ObjectId?,
  new_tree : @bit.ObjectId,
  db? : @bitlib.ObjectDb,
) -> Array[DiffFile] raise @bit.GitError {
  let db = match db {
    Some(db) => db
    None => @bitlib.ObjectDb::load(fs, git_dir)
  }
  let out : Array[DiffFile] = []
  let old_entries : Map[String, DiffHeadEntry] = Map([])
  let new_entries : Map[String, DiffHeadEntry] = Map([])
  match old_tree {
    Some(ot) => diff_collect_tree(db, fs, ot, "", old_entries)
    None => ()
  }
  diff_collect_tree(db, fs, new_tree, "", new_entries)
  // Added and modified
  for path, entry in new_entries {
    match old_entries.get(path) {
      None => {
        let new_content = diff_content_for_entry(db, fs, entry.id, entry.mode)
        if new_content is Some(_) {
          out.push({
            path,
            kind: DiffKind::Added,
            old_content: None,
            new_content,
            old_mode: None,
            new_mode: Some(entry.mode),
          })
        }
      }
      Some(old_entry) =>
        if old_entry.id != entry.id || old_entry.mode != entry.mode {
          let old_content = diff_content_for_entry(
            db,
            fs,
            old_entry.id,
            old_entry.mode,
          )
          let new_content = diff_content_for_entry(db, fs, entry.id, entry.mode)
          out.push({
            path,
            kind: DiffKind::Modified,
            old_content,
            new_content,
            old_mode: Some(old_entry.mode),
            new_mode: Some(entry.mode),
          })
        }
    }
  }
  // Deleted
  for path, entry in old_entries {
    if !new_entries.contains(path) {
      let old_content = diff_content_for_entry(db, fs, entry.id, entry.mode)
      if old_content is Some(_) {
        out.push({
          path,
          kind: DiffKind::Deleted,
          old_content,
          new_content: None,
          old_mode: Some(entry.mode),
          new_mode: None,
        })
      }
    }
  }
  out.sort_by(fn(a, b) { String::compare(a.path, b.path) })
  out
}

///|
fn is_gitlink_mode_int(mode : Int) -> Bool {
  mode == 0o160000
}

///|
fn mode_to_string(mode : Int) -> String {
  @string_utils.mode_to_string(mode)
}

///|
fn worktree_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
}

///|
priv enum AutoCrlf {
  Off
  Input
  On
}

///|
fn read_autocrlf_setting(
  fs : &@bit.RepoFileSystem,
  git_dir : String,
) -> AutoCrlf {
  let mut value : AutoCrlf? = None
  match @bitio.env_get("HOME") {
    None => ()
    Some(home) => {
      let candidates : Array[String] = []
      if @bitio.env_get("XDG_CONFIG_HOME") is Some(base) {
        candidates.push(base + "/git/config")
      }
      candidates.push(home + "/.gitconfig")
      for path in candidates {
        if fs.is_file(path) {
          value = read_autocrlf_from_config(fs, path)
          break
        }
      }
    }
  }
  let local_path = @bit.join_path(git_dir, "config")
  if fs.is_file(local_path) {
    match read_autocrlf_from_config(fs, local_path) {
      Some(v) => value = Some(v)
      None => ()
    }
  }
  match value {
    Some(v) => v
    None => AutoCrlf::Off
  }
}

///|
fn read_autocrlf_from_config(
  fs : &@bit.RepoFileSystem,
  path : String,
) -> AutoCrlf? {
  let bytes = fs.read_file(path) catch { _ => return None }
  let content = @utf8.decode_lossy(bytes[:])
  parse_autocrlf_from_content(content)
}

///|
fn parse_autocrlf_from_content(content : String) -> AutoCrlf? {
  let mut section = ""
  let mut value : AutoCrlf? = None
  for line_view in content.split("\n") {
    let line = line_view.trim().to_owned()
    if line.length() == 0 {
      continue
    }
    if line.has_prefix("#") || line.has_prefix(";") {
      continue
    }
    if line.has_prefix("[") && line.has_suffix("]") {
      let inner = String::unsafe_substring(line, start=1, end=line.length() - 1)
      let inner_trim = inner.trim().to_owned()
      let section_name = match inner_trim.find(" ") {
        Some(i) => String::unsafe_substring(inner_trim, start=0, end=i)
        None => inner_trim
      }
      section = section_name.to_lower()
      continue
    }
    if section != "core" {
      continue
    }
    match line.find("=") {
      None => ()
      Some(idx) => {
        let key = String::unsafe_substring(line, start=0, end=idx)
          .trim()
          .to_owned()
          .to_lower()
        if key != "autocrlf" {
          continue
        }
        let raw_value = String::unsafe_substring(
            line,
            start=idx + 1,
            end=line.length(),
          )
          .trim()
          .to_owned()
          .to_lower()
        value = raw_value |> parse_autocrlf_value |> Some
      }
    }
  }
  value
}

///|
fn parse_autocrlf_value(value : String) -> AutoCrlf {
  match value {
    "true" | "yes" | "1" => AutoCrlf::On
    "input" => AutoCrlf::Input
    _ => AutoCrlf::Off
  }
}

///|
fn normalize_worktree_content(content : Bytes, autocrlf : AutoCrlf) -> Bytes {
  match autocrlf {
    Off => content
    Input | On =>
      if is_binary_bytes(content) {
        content
      } else {
        normalize_crlf_to_lf(content)
      }
  }
}

///|
fn is_binary_bytes(content : Bytes) -> Bool {
  let limit = if content.length() > 8000 { 8000 } else { content.length() }
  for i in 0.. Bytes {
  let out : Array[Byte] = []
  let mut i = 0
  let mut changed = false
  while i < content.length() {
    if content[i] == b'\r' &&
      i + 1 < content.length() &&
      content[i + 1] == b'\n' {
      out.push(b'\n')
      i += 2
      changed = true
      continue
    }
    out.push(content[i])
    i += 1
  }
  if !changed {
    return content
  }
  Bytes::from_array(FixedArray::makei(out.length(), i => out[i]))
}

///|
fn profile_enabled() -> Bool {
  match @bitio.env_get("BIT_PROFILE") {
    Some(v) => v != "" && v != "0"
    None => false
  }
}

///|
fn profile_start(enabled : Bool) -> @bench.Timestamp? {
  if enabled {
    Some(@bench.monotonic_clock_start())
  } else {
    None
  }
}

///|
fn profile_lap(
  enabled : Bool,
  label : String,
  start : @bench.Timestamp?,
) -> @bench.Timestamp? {
  if !enabled {
    return start
  }
  match start {
    Some(ts) => {
      let elapsed_us = @bench.monotonic_clock_end(ts)
      let elapsed_ms = elapsed_us / 1000.0
      println("\{label}: \{elapsed_ms}ms")
      Some(@bench.monotonic_clock_start())
    }
    None => Some(@bench.monotonic_clock_start())
  }
}