//! VCS abstraction layer.
//!
//! The [`Vcs`] trait is synchronous — it is always called from within
//! `tokio::task::spawn_blocking`. [`GitVcs`] is the `git2`-backed implementation.

use std::collections::HashSet;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};

// ---------------------------------------------------------------------------
// Shared data types
// ---------------------------------------------------------------------------

/// The current Git operation state (rebase, merge, or normal).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GitMode {
    #[default]
    Normal,
    Rebase,
    Merge,
}

/// Snapshot of the repository's in-progress operation state, returned by
/// [`detect_git_op_state`].  Used by the Commit View to show mode badges and
/// gate the commit action.
#[derive(Debug, Clone, Default)]
pub struct GitOpState {
    pub mode: GitMode,
    /// True when the working tree contains at least one unresolved conflict.
    pub has_conflicts: bool,
    /// Pre-filled commit message (rebase step message or merge message), if any.
    pub message: String,
}

/// Inspect the repository at `root` and return the current Git operation state.
///
/// Runs synchronously; call from `tokio::task::spawn_blocking`.
pub fn detect_git_op_state(root: &Path) -> GitOpState {
    // Determine mode from well-known state directories / files.
    let git_dir = root.join(".git");
    let mode = if git_dir.join("rebase-merge").is_dir() || git_dir.join("rebase-apply").is_dir() {
        GitMode::Rebase
    } else if git_dir.join("MERGE_HEAD").is_file() {
        GitMode::Merge
    } else {
        GitMode::Normal
    };

    // Detect conflicts via git2 status flags.
    let has_conflicts = if let Ok(repo) = git2::Repository::open(root) {
        repo.statuses(None)
            .map(|statuses| {
                statuses
                    .iter()
                    .any(|e| e.status().intersects(git2::Status::CONFLICTED))
            })
            .unwrap_or(false)
    } else {
        false
    };

    // Load a pre-populated commit message for the current step.
    let message = match mode {
        GitMode::Rebase => fs::read_to_string(git_dir.join("rebase-merge/message"))
            .or_else(|_| fs::read_to_string(git_dir.join("COMMIT_EDITMSG")))
            .unwrap_or_default()
            .trim_end()
            .to_owned(),
        GitMode::Merge => fs::read_to_string(git_dir.join("MERGE_MSG"))
            .unwrap_or_default()
            .trim_end()
            .to_owned(),
        GitMode::Normal => String::new(),
    };

    GitOpState { mode, has_conflicts, message }
}

#[derive(Debug, Clone)]
pub struct StatusEntry {
    pub path: PathBuf,
    pub staged: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffKind {
    Context,
    Added,
    Removed,
    /// `@@` hunk header — not a real file line; used as a section separator.
    HunkHeader,
}

#[derive(Debug, Clone)]
pub struct DiffLine {
    /// Line number in the new (workdir) file, if applicable.
    pub line_no: Option<usize>,
    pub kind: DiffKind,
    pub content: String,
}

/// A single commit summary entry used by the git history viewer.
#[derive(Debug, Clone)]
pub struct CommitInfo {
    /// Full 40-character hex OID.
    pub oid: String,
    /// First line of the commit message, used for list display.
    pub summary: String,
    /// Full raw commit message (including the summary line and body).
    pub message: String,
    pub author_name: String,
    pub date_relative: String,
}

/// An instruction for an interactive rebase operation.
///
/// Passed to [`Vcs::run_interactive_rebase`] in **newest-first** order (matching
/// the `Vec<CommitInfo>` returned by [`Vcs::log_commits`]).
#[derive(Debug, Clone)]
pub enum RebaseInstruction {
    /// Keep the commit as-is.
    Pick { sha: String },
    /// Reword the commit message.
    Reword { sha: String, new_message: String },
    /// Squash this commit into the preceding (older) commit, using the given
    /// combined message.
    Squash { sha: String, new_message: String },
    /// Drop (delete) this commit.
    Drop { sha: String },
}

/// Change status for a file in a commit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileChangeStatus {
    Modified,
    Added,
    Deleted,
    Renamed,
}

impl FileChangeStatus {
    pub fn indicator(&self) -> &'static str {
        match self {
            Self::Modified => "M",
            Self::Added => "A",
            Self::Deleted => "D",
            Self::Renamed => "R",
        }
    }
}

/// A file changed within a commit.
#[derive(Debug, Clone)]
pub struct FileChange {
    pub path: PathBuf,
    pub status: FileChangeStatus,
}

// ---------------------------------------------------------------------------
// Trait
// ---------------------------------------------------------------------------

/// Backend-agnostic VCS interface.
///
/// All methods are synchronous; call them from `tokio::task::spawn_blocking`.
/// `Send` is required so the implementation can be moved into blocking tasks.
/// `Sync` is NOT required — if an async backend is added later, wrap in
/// `Arc<Mutex<dyn Vcs>>` at the call site.
pub trait Vcs: Send {
    /// Returns `(staged, unstaged)` file lists.
    fn status(&self) -> anyhow::Result<(Vec<StatusEntry>, Vec<StatusEntry>)>;

    /// Diff for a single file.
    /// `staged = true`  → HEAD ↔ index (what would be committed).
    /// `staged = false` → index ↔ workdir (what is not yet staged).
    fn diff_file(&self, path: &Path, staged: bool) -> anyhow::Result<Vec<DiffLine>>;

    fn stage_file(&self, path: &Path) -> anyhow::Result<()>;
    fn unstage_file(&self, path: &Path) -> anyhow::Result<()>;

    /// Stage only the selected lines (by index into `diff`).
    /// Uses direct git2 index blob manipulation — no subprocess required.
    fn stage_lines(&self, path: &Path, selected: &[usize], diff: &[DiffLine])
    -> anyhow::Result<()>;

    /// Unstage only the selected lines (revert them to HEAD content in the index).
    fn unstage_lines(
        &self,
        path: &Path,
        selected: &[usize],
        diff: &[DiffLine],
    ) -> anyhow::Result<()>;

    fn commit(&self, message: &str) -> anyhow::Result<()>;

    /// Amend the HEAD commit with a new message/tree (equivalent to `git commit --amend`).
    fn commit_amend(&self, message: &str) -> anyhow::Result<()>;

    /// Return HEAD commit message and OID, if HEAD exists: (message, oid)
    fn head_commit_message(&self) -> anyhow::Result<Option<(String, String)>>;

    /// Returns `(all_branches, current_branch)`.
    fn branches(&self) -> anyhow::Result<(Vec<String>, String)>;

    /// Returns remote-tracking branch names (e.g. `"origin/main"`).
    /// Symrefs such as `"origin/HEAD"` are excluded.
    fn remote_branches(&self) -> anyhow::Result<Vec<String>>;

    fn create_branch(&self, name: &str) -> anyhow::Result<()>;
    fn checkout_branch(&self, name: &str) -> anyhow::Result<()>;

    /// Return up to `max` commits reachable from HEAD, optionally filtered by
    /// a case-insensitive substring that matches summary, author name, or OID.
    fn log_commits(&self, max: usize, filter: &str) -> anyhow::Result<Vec<CommitInfo>>;

    /// Return the list of files changed in the commit identified by the full
    /// 40-char hex `oid`.
    fn commit_files(&self, oid: &str) -> anyhow::Result<Vec<FileChange>>;

    /// Return the diff for a single file within the commit identified by the
    /// full 40-char hex `oid`.
    fn commit_diff(&self, oid: &str, path: &Path) -> anyhow::Result<Vec<DiffLine>>;

    /// Return the set of 0-based line indices that differ from HEAD (working
    /// directory vs the HEAD tree).  Both staged and unstaged changes are
    /// included — this is what git-gutter indicators should reflect.
    fn changed_lines_vs_head(&self, path: &Path) -> anyhow::Result<HashSet<usize>>;

    /// Run an interactive rebase using the provided instructions.
    ///
    /// `instructions` is ordered **newest-first** (same order as [`log_commits`]
    /// output).  The method internally reverses the order for the git rebase
    /// todo file (which requires oldest-first).
    ///
    /// Returns the pre-operation HEAD OID (full 40-char hex) so the caller can
    /// push it onto an undo stack for `reset_hard`.
    fn run_interactive_rebase(
        &self,
        instructions: &[RebaseInstruction],
    ) -> anyhow::Result<String>;

    /// Hard-reset HEAD to the given full OID (for undo).
    fn reset_hard(&self, sha: &str) -> anyhow::Result<()>;
}

// ---------------------------------------------------------------------------
// GitVcs
// ---------------------------------------------------------------------------

pub struct GitVcs {
    /// Root path — used for resolving relative paths.
    pub root: PathBuf,
    repo: git2::Repository,
}

// git2::Repository doesn't implement Debug.
impl fmt::Debug for GitVcs {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("GitVcs")
            .field("root", &self.root)
            .finish_non_exhaustive()
    }
}

impl GitVcs {
    pub fn open(path: &Path) -> anyhow::Result<Self> {
        let repo = git2::Repository::open(path)?;
        Ok(Self {
            root: path.to_owned(),
            repo,
        })
    }
}

impl Vcs for GitVcs {
    // -----------------------------------------------------------------------
    // status
    // -----------------------------------------------------------------------

    fn status(&self) -> anyhow::Result<(Vec<StatusEntry>, Vec<StatusEntry>)> {
        let mut opts = git2::StatusOptions::new();
        opts.include_untracked(true)
            .recurse_untracked_dirs(true)
            .include_ignored(false);

        let statuses = self.repo.statuses(Some(&mut opts))?;

        let mut staged = Vec::new();
        let mut unstaged = Vec::new();

        for entry in statuses.iter() {
            let path = match entry.path() {
                Some(p) => PathBuf::from(p),
                None => continue,
            };
            let s = entry.status();

            let is_staged = s.intersects(
                git2::Status::INDEX_NEW
                    | git2::Status::INDEX_MODIFIED
                    | git2::Status::INDEX_DELETED
                    | git2::Status::INDEX_RENAMED
                    | git2::Status::INDEX_TYPECHANGE,
            );
            let is_unstaged = s.intersects(
                git2::Status::WT_MODIFIED
                    | git2::Status::WT_DELETED
                    | git2::Status::WT_RENAMED
                    | git2::Status::WT_TYPECHANGE
                    | git2::Status::WT_NEW,
            );

            if is_staged {
                staged.push(StatusEntry {
                    path: path.clone(),
                    staged: true,
                });
            }
            if is_unstaged {
                unstaged.push(StatusEntry {
                    path,
                    staged: false,
                });
            }
        }

        Ok((staged, unstaged))
    }

    // -----------------------------------------------------------------------
    // diff_file
    // -----------------------------------------------------------------------

    fn diff_file(&self, path: &Path, staged: bool) -> anyhow::Result<Vec<DiffLine>> {
        let mut diff_opts = git2::DiffOptions::new();
        diff_opts
            .pathspec(path.to_string_lossy().as_ref())
            // Include the full file as context so callers can fold at will.
            .context_lines(999_999);

        let diff = if staged {
            // HEAD tree ↔ index
            let head_tree = self.repo.head().ok().and_then(|h| h.peel_to_tree().ok());
            self.repo
                .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut diff_opts))?
        } else {
            // index ↔ workdir  (include untracked handled below)
            self.repo
                .diff_index_to_workdir(None, Some(&mut diff_opts))?
        };

        let mut lines: Vec<DiffLine> = Vec::new();

        diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
            let content = String::from_utf8_lossy(line.content())
                .trim_end_matches('\n')
                .trim_end_matches('\r')
                .to_string();

            match line.origin() {
                '+' => {
                    let line_no = line.new_lineno().map(|n| n as usize);
                    lines.push(DiffLine {
                        line_no,
                        kind: DiffKind::Added,
                        content,
                    });
                }
                '-' => {
                    let line_no = line.old_lineno().map(|n| n as usize);
                    lines.push(DiffLine {
                        line_no,
                        kind: DiffKind::Removed,
                        content,
                    });
                }
                ' ' => {
                    let line_no = line.new_lineno().map(|n| n as usize);
                    lines.push(DiffLine {
                        line_no,
                        kind: DiffKind::Context,
                        content,
                    });
                }
                'H' => {
                    // Hunk header — strip leading @@ prefix for display
                    lines.push(DiffLine {
                        line_no: None,
                        kind: DiffKind::HunkHeader,
                        content,
                    });
                }
                _ => {} // file headers, binary markers, etc.
            }
            true
        })?;

        // Untracked (new) files produce no diff against the index.
        // Read the file content directly and present it as all-Added lines.
        if !staged && lines.is_empty() {
            let abs = self.root.join(path);
            let in_index = self
                .repo
                .index()
                .ok()
                .and_then(|idx| idx.get_path(path, 0))
                .is_some();
            if abs.exists()
                && !in_index
                && let Ok(content) = fs::read_to_string(&abs)
            {
                lines.push(DiffLine {
                    line_no: None,
                    kind: DiffKind::HunkHeader,
                    content: "@@ new file @@".to_string(),
                });
                for (i, line_content) in content.lines().enumerate() {
                    lines.push(DiffLine {
                        line_no: Some(i + 1),
                        kind: DiffKind::Added,
                        content: line_content.to_owned(),
                    });
                }
            }
        }

        Ok(lines)
    }

    // -----------------------------------------------------------------------
    // stage_file / unstage_file
    // -----------------------------------------------------------------------

    fn stage_file(&self, path: &Path) -> anyhow::Result<()> {
        let mut index = self.repo.index()?;
        let abs = self.root.join(path);
        if abs.exists() {
            index.add_path(path)?;
        } else {
            // Deleted file — remove from index.
            index.remove_path(path)?;
        }
        index.write()?;
        Ok(())
    }

    fn unstage_file(&self, path: &Path) -> anyhow::Result<()> {
        // Reset path in index to HEAD state.
        match self.repo.head() {
            Ok(head_ref) => {
                let head_commit = head_ref.peel_to_commit()?;
                let mut checkout_opts = git2::build::CheckoutBuilder::new();
                checkout_opts.path(path).force();
                self.repo
                    .reset_default(Some(head_commit.as_object()), [path])?;
            }
            Err(_) => {
                // No HEAD (initial repo) — just remove from index.
                let mut index = self.repo.index()?;
                index.remove_path(path)?;
                index.write()?;
            }
        }
        Ok(())
    }

    // -----------------------------------------------------------------------
    // stage_lines / unstage_lines — direct index blob manipulation
    // -----------------------------------------------------------------------

    fn stage_lines(
        &self,
        path: &Path,
        selected: &[usize],
        diff: &[DiffLine],
    ) -> anyhow::Result<()> {
        // 1. Read current index content (or HEAD content if nothing is staged).
        let index_lines = self.index_file_lines(path)?;

        // 2. Build new index content by applying selected additions/removals.
        let new_content = apply_selected_lines(&index_lines, diff, selected, true)?;

        // 3. Write blob back to index.
        self.write_index_blob(path, &new_content)
    }

    fn unstage_lines(
        &self,
        path: &Path,
        selected: &[usize],
        diff: &[DiffLine],
    ) -> anyhow::Result<()> {
        // Same as stage_lines but inverse — reverts selected lines to HEAD.
        let index_lines = self.index_file_lines(path)?;
        let new_content = apply_selected_lines(&index_lines, diff, selected, false)?;
        self.write_index_blob(path, &new_content)
    }

    // -----------------------------------------------------------------------
    // commit
    // -----------------------------------------------------------------------

    fn commit(&self, message: &str) -> anyhow::Result<()> {
        let sig = self.repo.signature()?;
        let mut index = self.repo.index()?;
        let tree_oid = index.write_tree()?;
        let tree = self.repo.find_tree(tree_oid)?;

        match self.repo.head() {
            Ok(head_ref) => {
                let parent = head_ref.peel_to_commit()?;
                self.repo
                    .commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?;
            }
            Err(_) => {
                // Initial commit — no parent.
                self.repo
                    .commit(Some("HEAD"), &sig, &sig, message, &tree, &[])?;
            }
        }
        Ok(())
    }

    fn head_commit_message(&self) -> anyhow::Result<Option<(String, String)>> {
        if let Ok(head_ref) = self.repo.head()
            && let Some(oid) = head_ref.target() {
                let commit = self.repo.find_commit(oid)?;
                let msg = commit.message().unwrap_or("").to_string();
                return Ok(Some((msg, oid.to_string())));
            }
        Ok(None)
    }

    fn commit_amend(&self, message: &str) -> anyhow::Result<()> {
        use std::process::{Command, Stdio};
        use std::io::Write;

        // Use the git CLI for amend to avoid low-level libgit2 amend complexities.
        let mut cmd = Command::new("git");
        cmd.arg("-C").arg(self.root.to_string_lossy().to_string()).arg("commit").arg("--amend").arg("-F").arg("-").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
        let mut child = cmd.spawn()?;
        if let Some(mut stdin) = child.stdin.take() {
            stdin.write_all(message.as_bytes())?;
        }
        let out = child.wait_with_output()?;
        if out.status.success() {
            Ok(())
        } else {
            Err(anyhow::anyhow!(String::from_utf8_lossy(&out.stderr).to_string()))
        }
    }

    // -----------------------------------------------------------------------
    // branches
    // -----------------------------------------------------------------------

    fn branches(&self) -> anyhow::Result<(Vec<String>, String)> {
        let mut names = Vec::new();
        for branch in self.repo.branches(Some(git2::BranchType::Local))? {
            let (b, _) = branch?;
            if let Some(name) = b.name()? {
                names.push(name.to_owned());
            }
        }
        names.sort();

        let current = self
            .repo
            .head()
            .ok()
            .and_then(|h| h.shorthand().map(|s| s.to_owned()))
            .unwrap_or_else(|| "(detached)".to_owned());

        Ok((names, current))
    }

    fn remote_branches(&self) -> anyhow::Result<Vec<String>> {
        let mut names = Vec::new();
        for branch in self.repo.branches(Some(git2::BranchType::Remote))? {
            let (b, _) = branch?;
            if let Some(name) = b.name()? {
                // Skip symbolic refs like "origin/HEAD"
                if !name.ends_with("/HEAD") {
                    names.push(name.to_owned());
                }
            }
        }
        names.sort();
        Ok(names)
    }

    fn create_branch(&self, name: &str) -> anyhow::Result<()> {
        let head = self.repo.head()?.peel_to_commit()?;
        self.repo.branch(name, &head, false)?;
        Ok(())
    }

    fn checkout_branch(&self, name: &str) -> anyhow::Result<()> {
        let obj = self.repo.revparse_single(&format!("refs/heads/{}", name))?;
        let mut checkout = git2::build::CheckoutBuilder::new();
        checkout.safe();
        self.repo.checkout_tree(&obj, Some(&mut checkout))?;
        self.repo.set_head(&format!("refs/heads/{}", name))?;
        Ok(())
    }

    // -----------------------------------------------------------------------
    // log_commits
    // -----------------------------------------------------------------------

    fn log_commits(&self, max: usize, filter: &str) -> anyhow::Result<Vec<CommitInfo>> {
        let mut revwalk = self.repo.revwalk()?;
        revwalk.push_head()?;
        revwalk.set_sorting(git2::Sort::TIME | git2::Sort::TOPOLOGICAL)?;

        let filter_lower = filter.to_lowercase();
        let mut commits = Vec::new();

        for oid_result in revwalk {
            if commits.len() >= max {
                break;
            }
            let oid = oid_result?;
            let commit = self.repo.find_commit(oid)?;

            let summary = commit.summary().unwrap_or("").to_owned();
            let message = commit.message().unwrap_or("").to_owned();
            let author_name = commit.author().name().unwrap_or("").to_owned();
            let oid_str = oid.to_string();

            if !filter_lower.is_empty() {
                let matches = summary.to_lowercase().contains(&filter_lower)
                    || author_name.to_lowercase().contains(&filter_lower)
                    || oid_str.contains(&filter_lower);
                if !matches {
                    continue;
                }
            }

            let date_relative = format_relative_time(commit.time().seconds());
            commits.push(CommitInfo {
                oid: oid_str,
                summary,
                message,
                author_name,
                date_relative,
            });
        }

        Ok(commits)
    }

    // -----------------------------------------------------------------------
    // commit_files
    // -----------------------------------------------------------------------

    fn commit_files(&self, oid: &str) -> anyhow::Result<Vec<FileChange>> {
        let oid = git2::Oid::from_str(oid)?;
        let commit = self.repo.find_commit(oid)?;
        let tree = commit.tree()?;

        let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());

        let mut diff_opts = git2::DiffOptions::new();
        let diff = self
            .repo
            .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;

        let mut files = Vec::new();
        diff.foreach(
            &mut |delta, _progress| {
                let status = match delta.status() {
                    git2::Delta::Added | git2::Delta::Untracked => FileChangeStatus::Added,
                    git2::Delta::Deleted => FileChangeStatus::Deleted,
                    git2::Delta::Renamed | git2::Delta::Copied => FileChangeStatus::Renamed,
                    _ => FileChangeStatus::Modified,
                };
                let path = delta
                    .new_file()
                    .path()
                    .or_else(|| delta.old_file().path())
                    .map(PathBuf::from)
                    .unwrap_or_default();
                files.push(FileChange { path, status });
                true
            },
            None,
            None,
            None,
        )?;

        Ok(files)
    }

    // -----------------------------------------------------------------------
    // commit_diff
    // -----------------------------------------------------------------------

    fn commit_diff(&self, oid: &str, path: &Path) -> anyhow::Result<Vec<DiffLine>> {
        let oid = git2::Oid::from_str(oid)?;
        let commit = self.repo.find_commit(oid)?;
        let tree = commit.tree()?;

        let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());

        let mut diff_opts = git2::DiffOptions::new();
        diff_opts
            .pathspec(path.to_string_lossy().as_ref())
            .context_lines(999_999);

        let diff = self
            .repo
            .diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut diff_opts))?;

        let mut lines: Vec<DiffLine> = Vec::new();
        diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
            let content = String::from_utf8_lossy(line.content())
                .trim_end_matches('\n')
                .trim_end_matches('\r')
                .to_string();
            match line.origin() {
                '+' => lines.push(DiffLine {
                    line_no: line.new_lineno().map(|n| n as usize),
                    kind: DiffKind::Added,
                    content,
                }),
                '-' => lines.push(DiffLine {
                    line_no: line.old_lineno().map(|n| n as usize),
                    kind: DiffKind::Removed,
                    content,
                }),
                ' ' => lines.push(DiffLine {
                    line_no: line.new_lineno().map(|n| n as usize),
                    kind: DiffKind::Context,
                    content,
                }),
                'H' => lines.push(DiffLine {
                    line_no: None,
                    kind: DiffKind::HunkHeader,
                    content,
                }),
                _ => {}
            }
            true
        })?;

        Ok(lines)
    }

    fn changed_lines_vs_head(&self, path: &Path) -> anyhow::Result<HashSet<usize>> {
        // git2::Index::get_path (called below) panics on absolute paths — guard early.
        anyhow::ensure!(
            path.is_relative(),
            "changed_lines_vs_head requires a repo-relative path, got: {}",
            path.display()
        );

        let mut diff_opts = git2::DiffOptions::new();
        diff_opts
            .pathspec(path.to_string_lossy().as_ref())
            .context_lines(0); // no context — only changed lines needed

        // HEAD tree → workdir (captures both staged and unstaged changes)
        let head_tree = self.repo.head().ok().and_then(|h| h.peel_to_tree().ok());
        let diff = self
            .repo
            .diff_tree_to_workdir(head_tree.as_ref(), Some(&mut diff_opts))?;

        let mut changed: HashSet<usize> = HashSet::new();
        diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
            if line.origin() == '+'
                && let Some(n) = line.new_lineno() {
                    changed.insert(n.saturating_sub(1) as usize); // 1-based → 0-based
                }
            true
        })?;

        // New/untracked file: HEAD tree diff returns nothing; detect via index absence.
        if changed.is_empty() {
            let abs = self.root.join(path);
            let in_index = self
                .repo
                .index()
                .ok()
                .and_then(|idx| idx.get_path(path, 0))
                .is_some();
            let has_head = head_tree.is_some()
                && head_tree
                    .as_ref()
                    .and_then(|t| t.get_path(path).ok())
                    .is_some();
            if !has_head && abs.exists()
                && let Ok(content) = fs::read_to_string(&abs) {
                    let _ = in_index; // suppress unused warning
                    for i in 0..content.lines().count() {
                        changed.insert(i);
                    }
                }
        }

        Ok(changed)
    }

    fn run_interactive_rebase(
        &self,
        instructions: &[RebaseInstruction],
    ) -> anyhow::Result<String> {
        use std::io::Write as _;
        use std::process::{Command, Stdio};

        // Save pre-operation HEAD sha for undo.
        let pre_op_sha = self
            .repo
            .head()?
            .peel_to_commit()
            .map(|c| c.id().to_string())?;

        // Base ref: parent of the oldest commit we're rebasing onto.
        let base_ref = format!("HEAD~{}", instructions.len());

        // Build todo file (oldest-first: instructions are newest-first, so reverse).
        let mut todo = String::new();
        for instr in instructions.iter().rev() {
            let line = match instr {
                RebaseInstruction::Pick { sha } => format!("pick {sha}\n"),
                RebaseInstruction::Reword { sha, .. } => format!("reword {sha}\n"),
                RebaseInstruction::Squash { sha, .. } => format!("squash {sha}\n"),
                RebaseInstruction::Drop { sha } => format!("drop {sha}\n"),
            };
            todo.push_str(&line);
        }

        // Collect new messages (for reword / squash instructions).
        let messages: Vec<&str> = instructions
            .iter()
            .filter_map(|i| match i {
                RebaseInstruction::Reword { new_message, .. }
                | RebaseInstruction::Squash { new_message, .. } => Some(new_message.as_str()),
                _ => None,
            })
            .collect();

        // Create a per-repo temp directory to avoid file collisions.
        let repo_hash = {
            use std::hash::{Hash, Hasher};
            let mut h = std::collections::hash_map::DefaultHasher::new();
            self.root.hash(&mut h);
            h.finish()
        };
        let tmp = std::env::temp_dir().join(format!("oo_rebase_{repo_hash:x}"));
        std::fs::create_dir_all(&tmp)?;

        // Write todo file.
        let todo_path = tmp.join("git-rebase-todo");
        std::fs::write(&todo_path, &todo)?;

        // Write sequence-editor script: copies our todo over the git todo.
        let seq_editor_path = tmp.join("seq-editor.sh");
        {
            let mut f = std::fs::File::create(&seq_editor_path)?;
            writeln!(f, "#!/bin/sh")?;
            writeln!(f, "cp '{}' \"$1\"", todo_path.display())?;
        }
        set_executable(&seq_editor_path)?;

        // If there are messages, write a GIT_EDITOR script that feeds them in
        // order (one per invocation, advancing a counter via a temp state file).
        let git_editor_path = tmp.join("git-editor.sh");
        if !messages.is_empty() {
            // Write message files msg_0.txt, msg_1.txt, …
            for (i, msg) in messages.iter().enumerate() {
                std::fs::write(tmp.join(format!("msg_{i}.txt")), msg)?;
            }
            // State file: tracks which message index is next.
            std::fs::write(tmp.join("msg_idx.txt"), "0")?;
            let mut f = std::fs::File::create(&git_editor_path)?;
            writeln!(f, "#!/bin/sh")?;
            writeln!(f, "IDX_FILE='{}'", tmp.join("msg_idx.txt").display())?;
            writeln!(f, "IDX=$(cat \"$IDX_FILE\")")?;
            writeln!(f, "MSG_FILE='{}/msg_'\"$IDX\"'.txt'", tmp.display())?;
            writeln!(f, "cp \"$MSG_FILE\" \"$1\"")?;
            writeln!(f, "echo $((IDX + 1)) > \"$IDX_FILE\"")?;
        } else {
            let mut f = std::fs::File::create(&git_editor_path)?;
            writeln!(f, "#!/bin/sh")?;
            writeln!(f, "true")?;
        }
        set_executable(&git_editor_path)?;

        let out = Command::new("git")
            .arg("-C")
            .arg(self.root.to_string_lossy().to_string())
            .arg("rebase")
            .arg("-i")
            .arg("--no-autosquash")
            .arg(&base_ref)
            .env("GIT_SEQUENCE_EDITOR", seq_editor_path.to_string_lossy().to_string())
            .env("GIT_EDITOR", git_editor_path.to_string_lossy().to_string())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .output()?;

        // Clean up temp dir.
        let _ = std::fs::remove_dir_all(&tmp);

        if out.status.success() {
            Ok(pre_op_sha)
        } else {
            let stderr = String::from_utf8_lossy(&out.stderr);
            let stdout = String::from_utf8_lossy(&out.stdout);
            Err(anyhow::anyhow!("{}{}", stdout, stderr))
        }
    }

    fn reset_hard(&self, sha: &str) -> anyhow::Result<()> {
        let oid = git2::Oid::from_str(sha)?;
        let commit = self.repo.find_commit(oid)?;
        let mut checkout = git2::build::CheckoutBuilder::new();
        checkout.force();
        self.repo
            .reset(commit.as_object(), git2::ResetType::Hard, Some(&mut checkout))?;
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// GitVcs helpers
// ---------------------------------------------------------------------------

impl GitVcs {
    /// Return the current index content for `path` as lines, falling back to
    /// HEAD content if the path isn't yet in the index, or empty for new files.
    fn index_file_lines(&self, path: &Path) -> anyhow::Result<Vec<String>> {
        let index = self.repo.index()?;
        if let Some(entry) = index.get_path(path, 0) {
            let blob = self.repo.find_blob(entry.id)?;
            let text = std::str::from_utf8(blob.content())?.to_owned();
            return Ok(split_lines(&text));
        }
        // Try HEAD tree.
        if let Ok(head) = self.repo.head()
            && let Ok(tree) = head.peel_to_tree()
            && let Ok(entry) = tree.get_path(path)
            && let Ok(obj) = entry.to_object(&self.repo)
            && let Some(blob) = obj.as_blob()
        {
            let text = std::str::from_utf8(blob.content())?.to_owned();
            return Ok(split_lines(&text));
        }
        Ok(Vec::new())
    }

    /// Write `content` as a blob into the index for `path`.
    fn write_index_blob(&self, path: &Path, content: &str) -> anyhow::Result<()> {
        let oid = self.repo.blob(content.as_bytes())?;
        let mut index = self.repo.index()?;

        // Build an IndexEntry — copy the existing one if present, else synthesise.
        let mut entry = index.get_path(path, 0).unwrap_or_else(|| git2::IndexEntry {
            ctime: git2::IndexTime::new(0, 0),
            mtime: git2::IndexTime::new(0, 0),
            dev: 0,
            ino: 0,
            mode: 0o100644,
            uid: 0,
            gid: 0,
            file_size: 0,
            id: git2::Oid::zero(),
            flags: 0,
            flags_extended: 0,
            path: path.to_string_lossy().as_bytes().to_vec(),
        });
        entry.id = oid;
        entry.file_size = content.len() as u32;

        index.add(&entry)?;
        index.write()?;
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// Line-level patch helpers
// ---------------------------------------------------------------------------

/// Apply (or reverse-apply) selected diff lines to `base_lines`.
///
/// `apply = true`  → stage: add/remove the selected changes into the base.
/// `apply = false` → unstage: revert the selected changes (keep the rest).
fn apply_selected_lines(
    base_lines: &[String],
    diff: &[DiffLine],
    selected: &[usize],
    apply: bool,
) -> anyhow::Result<String> {
    let selected_set: std::collections::HashSet<usize> = selected.iter().copied().collect();

    // We replay the diff against `base_lines`.
    // base_pos tracks our position in base_lines.
    let mut result: Vec<String> = Vec::new();
    let mut base_pos: usize = 0;

    for (i, dl) in diff.iter().enumerate() {
        match &dl.kind {
            DiffKind::HunkHeader => {} // not a real content line — skip
            DiffKind::Context => {
                // Consume one line from base unchanged.
                if base_pos < base_lines.len() {
                    result.push(base_lines[base_pos].clone());
                    base_pos += 1;
                }
            }
            DiffKind::Added => {
                if apply && selected_set.contains(&i) {
                    result.push(dl.content.clone());
                } else if !apply && !selected_set.contains(&i) {
                    // Keep already-staged lines that are NOT being unstaged.
                    result.push(dl.content.clone());
                }
                // If not selected (stage) or selected (unstage), drop the line.
            }
            DiffKind::Removed => {
                if apply && selected_set.contains(&i) {
                    // Remove this line from base — skip consuming it.
                    base_pos += 1;
                } else {
                    // Keep it.
                    if base_pos < base_lines.len() {
                        result.push(base_lines[base_pos].clone());
                        base_pos += 1;
                    }
                }
            }
        }
    }

    // Append any remaining base lines.
    while base_pos < base_lines.len() {
        result.push(base_lines[base_pos].clone());
        base_pos += 1;
    }

    Ok(result.join("\n") + "\n")
}

fn split_lines(text: &str) -> Vec<String> {
    // Preserve last empty line correctly.
    let lines: Vec<String> = text.lines().map(|l| l.to_owned()).collect();
    // `str::lines` drops a trailing newline; re-add empty last element if needed.
    if text.ends_with('\n') && !lines.is_empty() {
        // nothing — lines() already stripped the final newline token
    }
    lines
}

/// Format a Unix timestamp as a human-readable relative time string.
fn format_relative_time(unix_secs: i64) -> String {
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    let delta = now.saturating_sub(unix_secs);
    if delta < 60 {
        "just now".to_owned()
    } else if delta < 3_600 {
        format!("{}m ago", delta / 60)
    } else if delta < 86_400 {
        format!("{}h ago", delta / 3_600)
    } else if delta < 86_400 * 30 {
        format!("{}d ago", delta / 86_400)
    } else if delta < 86_400 * 365 {
        format!("{}mo ago", delta / (86_400 * 30))
    } else {
        format!("{}y ago", delta / (86_400 * 365))
    }
}

/// Set the executable bit on `path` (Unix only).
/// On non-Unix platforms this is a no-op.
fn set_executable(path: &std::path::Path) -> std::io::Result<()> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?;
    }
    #[cfg(not(unix))]
    {
        let _ = path;
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_apply_selected_add() {
        let base = vec!["line1".to_owned(), "line2".to_owned()];
        let diff = vec![
            DiffLine {
                line_no: Some(1),
                kind: DiffKind::Context,
                content: "line1".into(),
            },
            DiffLine {
                line_no: Some(2),
                kind: DiffKind::Added,
                content: "new".into(),
            },
            DiffLine {
                line_no: Some(3),
                kind: DiffKind::Context,
                content: "line2".into(),
            },
        ];
        let result = apply_selected_lines(&base, &diff, &[1], true).unwrap();
        assert_eq!(result, "line1\nnew\nline2\n");
    }

    #[test]
    fn test_apply_selected_remove() {
        let base = vec!["line1".to_owned(), "old".to_owned(), "line2".to_owned()];
        let diff = vec![
            DiffLine {
                line_no: Some(1),
                kind: DiffKind::Context,
                content: "line1".into(),
            },
            DiffLine {
                line_no: None,
                kind: DiffKind::Removed,
                content: "old".into(),
            },
            DiffLine {
                line_no: Some(2),
                kind: DiffKind::Context,
                content: "line2".into(),
            },
        ];
        let result = apply_selected_lines(&base, &diff, &[1], true).unwrap();
        assert_eq!(result, "line1\nline2\n");
    }

    // -----------------------------------------------------------------------
    // Helpers for git-integration tests
    // -----------------------------------------------------------------------

    /// Create a minimal in-memory git repo in `dir`, commit `content` as `filename`,
    /// and return the resulting `GitVcs`.
    fn make_committed_repo(dir: &std::path::Path, filename: &str, content: &str) -> GitVcs {
        use git2::{Repository, Signature};
        let repo = Repository::init(dir).unwrap();

        // Configure identity so commit doesn't fail.
        let mut cfg = repo.config().unwrap();
        cfg.set_str("user.name", "Test").unwrap();
        cfg.set_str("user.email", "test@example.com").unwrap();
        drop(cfg);

        // Write file.
        let file_path = dir.join(filename);
        std::fs::write(&file_path, content).unwrap();

        // Stage and commit in a block so the tree borrow is released before `repo` is moved.
        {
            let mut index = repo.index().unwrap();
            index.add_path(std::path::Path::new(filename)).unwrap();
            index.write().unwrap();
            let tree_oid = index.write_tree().unwrap();
            let tree = repo.find_tree(tree_oid).unwrap();
            let sig = Signature::now("Test", "test@example.com").unwrap();
            repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[]).unwrap();
        }

        GitVcs { root: dir.to_path_buf(), repo }
    }

    #[test]
    fn changed_lines_vs_head_detects_unstaged_modification() {
        let dir = tempfile::tempdir().unwrap();
        let vcs = make_committed_repo(dir.path(), "file.txt", "line1\nline2\nline3\n");

        // Modify line 2 in workdir (0-based index 1) without staging.
        std::fs::write(dir.path().join("file.txt"), "line1\nMODIFIED\nline3\n").unwrap();

        let changed = vcs.changed_lines_vs_head(std::path::Path::new("file.txt")).unwrap();
        assert!(changed.contains(&1), "line 2 (0-based 1) should be marked changed; got {changed:?}");
        assert!(!changed.contains(&0), "line 1 unchanged");
        assert!(!changed.contains(&2), "line 3 unchanged");
    }

    #[test]
    fn changed_lines_vs_head_detects_staged_modification() {
        let dir = tempfile::tempdir().unwrap();
        let vcs = make_committed_repo(dir.path(), "file.txt", "line1\nline2\nline3\n");

        // Modify and STAGE line 2.
        std::fs::write(dir.path().join("file.txt"), "line1\nSTAGED\nline3\n").unwrap();
        let mut index = vcs.repo.index().unwrap();
        index.add_path(std::path::Path::new("file.txt")).unwrap();
        index.write().unwrap();

        let changed = vcs.changed_lines_vs_head(std::path::Path::new("file.txt")).unwrap();
        assert!(changed.contains(&1), "staged modification should appear; got {changed:?}");
        assert!(!changed.contains(&0));
        assert!(!changed.contains(&2));
    }

    #[test]
    fn changed_lines_vs_head_detects_added_lines() {
        let dir = tempfile::tempdir().unwrap();
        let vcs = make_committed_repo(dir.path(), "file.txt", "line1\nline2\n");

        // Append a new line.
        std::fs::write(dir.path().join("file.txt"), "line1\nline2\nnew_line\n").unwrap();

        let changed = vcs.changed_lines_vs_head(std::path::Path::new("file.txt")).unwrap();
        assert!(changed.contains(&2), "newly added line (0-based 2) should be marked; got {changed:?}");
        assert!(!changed.contains(&0));
        assert!(!changed.contains(&1));
    }

    #[test]
    fn changed_lines_vs_head_empty_for_unchanged_file() {
        let dir = tempfile::tempdir().unwrap();
        let vcs = make_committed_repo(dir.path(), "file.txt", "line1\nline2\n");

        // No workdir changes.
        let changed = vcs.changed_lines_vs_head(std::path::Path::new("file.txt")).unwrap();
        assert!(changed.is_empty(), "no changes → empty set; got {changed:?}");
    }

    #[test]
    fn changed_lines_vs_head_new_untracked_file_all_lines() {
        let dir = tempfile::tempdir().unwrap();
        // Create a repo with a different file committed.
        let vcs = make_committed_repo(dir.path(), "other.txt", "x\n");

        // Write a brand-new file that is not in HEAD at all.
        std::fs::write(dir.path().join("new.txt"), "a\nb\nc\n").unwrap();

        let changed = vcs.changed_lines_vs_head(std::path::Path::new("new.txt")).unwrap();
        assert_eq!(changed.len(), 3, "all 3 lines of new file should be marked; got {changed:?}");
        assert!(changed.contains(&0));
        assert!(changed.contains(&1));
        assert!(changed.contains(&2));
    }
}
