/* git/rebase.rs
 *
 * Copyright 2026 Christian Kruse
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * SPDX-License-Identifier: AGPL-3.0-or-later
 */

use std::collections::HashSet;
use std::io::Write;
use std::path::{Path, PathBuf};

use git2::{Commit, Oid, Repository};
use tempfile::NamedTempFile;

use super::status::dirty_summary_repo;
use super::types::RebaseOutcome;
use super::{git_err, open_repo};

#[cfg(test)]
mod tests;

#[derive(Debug)]
pub struct SquashCommitMessage {
    pub subject: String,
    pub body: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SquashMode {
    Squash,
    Fixup,
}

pub fn squash_commits(
    repo_path: &Path,
    git_command: &[String],
    oids: &[String],
    mode: SquashMode,
    new_message: Option<String>,
) -> Result<RebaseOutcome, String> {
    if oids.len() < 2 {
        return Err("Select at least two commits to squash".to_string());
    }

    let repo = open_repo(repo_path)?;
    ensure_clean(&repo, "squash")?;

    let selected: HashSet<Oid> = parse_oids(oids)?;

    let TargetWalk {
        mut walk,
        oldest_target: oldest,
    } = walk_for_targets(&repo, &selected)?;

    reject_merges(&walk, "squash")?;

    if oldest.parent_count() == 0 {
        return Err("Cannot squash the root commit".to_string());
    }

    let onto = oldest.parent(0).map_err(git_err("Failed to read base parent"))?.id();
    walk.reverse();

    // The squash target (the one all others fold INTO) is the oldest selected commit. Build the todo so all selected
    // commits are picked first (in chronological order), then the non-selected ones are picked on top
    let oldest_selected_oid = oldest.id();

    let combined_message = match mode {
        SquashMode::Fixup => None,
        SquashMode::Squash => Some(new_message.unwrap_or_else(|| combined_message(&walk, &selected))),
    };

    let mut todo = String::new();
    let mut squash_message_file: Option<NamedTempFile> = None;

    todo.push_str(&format!("pick {oldest_selected_oid}\n"));

    for c in &walk {
        let oid = c.id();

        if oid == oldest_selected_oid {
            continue;
        }

        if selected.contains(&oid) {
            match mode {
                SquashMode::Fixup => todo.push_str(&format!("fixup {oid}\n")),
                SquashMode::Squash => todo.push_str(&format!("squash {oid}\n")),
            }
        }
    }

    for c in &walk {
        let oid = c.id();

        if !selected.contains(&oid) {
            todo.push_str(&format!("pick {oid}\n"));
        }
    }

    let todo_file = write_temp(&todo)?;

    if let Some(msg) = combined_message {
        squash_message_file = Some(write_temp(&msg)?);
    }

    let editor_msg_path = squash_message_file.as_ref().map(|f| f.path().to_path_buf());
    let expected_commits = walk.len() - oids.len() + 1;

    run_rebase(
        &repo,
        repo_path,
        git_command,
        onto,
        todo_file.path(),
        editor_msg_path.as_deref(),
        Some(expected_commits),
    )
}

pub fn reword_commit(
    repo_path: &Path,
    git_command: &[String],
    oid_str: &str,
    new_message: &str,
) -> Result<RebaseOutcome, String> {
    let repo = open_repo(repo_path)?;
    ensure_clean(&repo, "reword")?;

    let target_oid = Oid::from_str(oid_str).map_err(git_err("Invalid OID"))?;
    let mut targets = HashSet::new();
    targets.insert(target_oid);

    let TargetWalk {
        walk,
        oldest_target: target_commit,
    } = walk_for_targets(&repo, &targets).map_err(map_unreachable_one)?;

    reject_merges(&walk, "reword")?;

    if target_commit.parent_count() == 0 {
        return Err("Cannot reword the root commit".to_string());
    }

    let onto = target_commit
        .parent(0)
        .map_err(git_err("Failed to read parent of target commit"))?
        .id();

    cleanup_rewrite_dir(&repo);

    // Persist the new message in a temp file so the exec line can pick it up even after
    // a conflict-resume.

    let msg_path = stash_message(&repo, &target_oid.to_string(), new_message)?;

    let mut todo = String::new();
    for c in walk.iter().rev() {
        let oid = c.id();
        todo.push_str(&format!("pick {oid}\n"));

        if oid == target_oid {
            todo.push_str(&format!(
                "exec git commit --allow-empty --amend -F {}\n",
                shell_words::quote(msg_path.to_string_lossy().as_ref())
            ));
        }
    }

    let todo_file = write_temp(&todo)?;
    let expected_commits = walk.len();

    run_rebase(&repo, repo_path, git_command, onto, todo_file.path(), None, Some(expected_commits))
}

pub fn edit_commit(repo_path: &Path, git_command: &[String], oid_str: &str) -> Result<RebaseOutcome, String> {
    let repo = open_repo(repo_path)?;
    ensure_clean(&repo, "edit")?;

    let target_oid = Oid::from_str(oid_str).map_err(git_err("Invalid OID"))?;
    let mut targets = HashSet::new();
    targets.insert(target_oid);

    let TargetWalk {
        walk,
        oldest_target: target_commit,
    } = walk_for_targets(&repo, &targets).map_err(map_unreachable_one)?;

    reject_merges(&walk, "edit")?;

    if target_commit.parent_count() == 0 {
        return Err("Cannot edit the root commit".to_string());
    }

    let onto = target_commit
        .parent(0)
        .map_err(git_err("Failed to read parent of target commit"))?
        .id();

    let mut todo = String::new();

    for c in walk.iter().rev() {
        let oid = c.id();

        if oid == target_oid {
            todo.push_str(&format!("edit {oid}\n"));
        } else {
            todo.push_str(&format!("pick {oid}\n"));
        }
    }

    let todo_file = write_temp(&todo)?;
    let expected_commits = walk.len();

    run_rebase(&repo, repo_path, git_command, onto, todo_file.path(), None, Some(expected_commits))
}

pub fn drop_commit(repo_path: &Path, git_command: &[String], oid_str: &str) -> Result<RebaseOutcome, String> {
    let repo = open_repo(repo_path)?;
    ensure_clean(&repo, "drop")?;

    let target_oid = Oid::from_str(oid_str).map_err(git_err("Invalid OID"))?;
    let mut targets = HashSet::new();
    targets.insert(target_oid);

    let TargetWalk {
        walk,
        oldest_target: target_commit,
    } = walk_for_targets(&repo, &targets).map_err(map_unreachable_one)?;

    reject_merges(&walk, "drop")?;

    if target_commit.parent_count() == 0 {
        return Err("Cannot drop the root commit".to_string());
    }

    let onto = target_commit
        .parent(0)
        .map_err(git_err("Failed to read parent of target commit"))?
        .id();

    let mut todo = String::new();
    for c in walk.iter().rev() {
        if c.id() == target_oid {
            continue;
        }

        todo.push_str(&format!("pick {}\n", c.id()));
    }

    // HEAD == target with nothing newer: dropping it means HEAD moves to its parent.
    // `git rebase -i` refuses an empty todo, so do the move directly.
    if todo.is_empty() {
        return reset_head_to(repo_path, git_command, onto);
    }

    let todo_file = write_temp(&todo)?;
    let expected_commits = walk.len() - 1;
    run_rebase(&repo, repo_path, git_command, onto, todo_file.path(), None, Some(expected_commits))
}

pub fn reorder_commits(
    repo_path: &Path,
    git_command: &[String],
    new_order: &[String],
) -> Result<RebaseOutcome, String> {
    if new_order.len() < 2 {
        return Err("Reordering needs at least two commits".to_string());
    }

    let repo = open_repo(repo_path)?;
    ensure_clean(&repo, "reorder")?;

    let target_oids: Vec<Oid> = new_order
        .iter()
        .map(|s| Oid::from_str(s).map_err(git_err("Invalid OID")))
        .collect::<Result<_, _>>()?;

    let target_set: HashSet<Oid> = target_oids.iter().copied().collect();

    if target_set.len() != target_oids.len() {
        return Err("New order contains duplicate commits".to_string());
    }

    let head_commit = repo
        .head()
        .map_err(git_err("Failed to read HEAD"))?
        .peel_to_commit()
        .map_err(git_err("Failed to peel HEAD to commit"))?;

    let mut walk: Vec<Commit> = Vec::new();
    let mut current = head_commit;
    let mut remaining: HashSet<Oid> = target_set.clone();

    loop {
        if !target_set.contains(&current.id()) {
            return Err("Reorder range must be contiguous from HEAD downward".to_string());
        }

        remaining.remove(&current.id());
        walk.push(current.clone());

        if remaining.is_empty() {
            break;
        }

        if current.parent_count() == 0 {
            return Err("Selected commits are not all reachable from HEAD via first-parent history".to_string());
        }

        current = current.parent(0).map_err(git_err("Failed to walk to first parent"))?;
    }

    reject_merges(&walk, "reorder")?;

    let oldest = walk.last().expect("walk non-empty").clone();

    if oldest.parent_count() == 0 {
        return Err("Cannot reorder the root commit".to_string());
    }

    let onto = oldest.parent(0).map_err(git_err("Failed to read base parent"))?.id();
    let mut todo = String::new();

    for oid in &target_oids {
        todo.push_str(&format!("pick {oid}\n"));
    }

    let todo_file = write_temp(&todo)?;
    let expected_commits = target_oids.len();
    run_rebase(&repo, repo_path, git_command, onto, todo_file.path(), None, Some(expected_commits))
}

pub fn rebase_onto(
    repo_path: &Path,
    git_command: &[String],
    upstream: Oid,
    expected_commits: usize,
) -> Result<RebaseOutcome, String> {
    let repo = open_repo(repo_path)?;

    let mut cmd = super::git_command(Some(repo_path), git_command, None);
    cmd.args(["rebase", &upstream.to_string()]);
    cmd.env("GIT_EDITOR", "true").env("GIT_SEQUENCE_EDITOR", "true");

    let output = cmd.output().map_err(|e| format!("Failed to spawn git: {e}"))?;
    classify_outcome(&repo, &output, Some(expected_commits))
}

pub fn rebase_branch_or_oid(
    repo_path: &Path,
    git_command: &[String],
    upstream: Oid,
    branch_or_oid: &str,
    expected_commits: usize,
) -> Result<RebaseOutcome, String> {
    let repo = open_repo(repo_path)?;

    let mut cmd = super::git_command(Some(repo_path), git_command, None);
    cmd.args(["rebase", &upstream.to_string(), branch_or_oid]);
    cmd.env("GIT_EDITOR", "true").env("GIT_SEQUENCE_EDITOR", "true");

    let output = cmd.output().map_err(|e| format!("Failed to spawn git: {e}"))?;
    classify_outcome(&repo, &output, Some(expected_commits))
}

pub fn rebase_continue(repo_path: &Path, git_command: &[String]) -> Result<RebaseOutcome, String> {
    let repo = open_repo(repo_path)?;

    // Verify the index has no remaining conflicts before asking git to continue.
    if repo.index().map_err(git_err("Failed to open index"))?.has_conflicts() {
        return Err("Cannot continue rebase: there are still unresolved conflicts".to_string());
    }

    let expected_commits = count_remaining_commit_ops(&repo);

    let mut cmd = super::git_command(Some(repo_path), git_command, None);
    cmd.args(["rebase", "--continue"]);
    cmd.env("GIT_EDITOR", "true").env("GIT_SEQUENCE_EDITOR", "true");

    let output = cmd.output().map_err(|e| format!("Failed to spawn git: {e}"))?;
    classify_outcome(&repo, &output, expected_commits)
}

fn todo_command(line: &str) -> Option<&str> {
    let t = line.trim_start();

    if t.is_empty() || t.starts_with('#') {
        return None;
    }

    t.split_whitespace().next()
}

fn count_remaining_commit_ops(repo: &Repository) -> Option<usize> {
    let dir = repo.path().join("rebase-merge");
    let todo = std::fs::read_to_string(dir.join("git-rebase-todo")).ok()?;

    let in_todo = todo
        .lines()
        .filter_map(todo_command)
        .filter(|c| matches!(*c, "pick" | "p" | "reword" | "r" | "edit" | "e"))
        .count();

    let paused_creates_commit = std::fs::read_to_string(dir.join("done"))
        .ok()
        .as_deref()
        .and_then(|d| d.lines().rev().find_map(todo_command).map(str::to_owned))
        .map(|c| matches!(c.as_str(), "pick" | "p" | "reword" | "r" | "edit" | "e"))
        .unwrap_or(true);

    Some(in_todo + paused_creates_commit as usize)
}

pub fn rebase_abort(repo_path: &Path, git_command: &[String]) -> Result<(), String> {
    let mut cmd = super::git_command(Some(repo_path), git_command, None);
    cmd.args(["rebase", "--abort"]);

    let output = cmd.output().map_err(|e| format!("Failed to spawn git: {e}"))?;

    if output.status.success() {
        if let Ok(repo) = open_repo(repo_path) {
            cleanup_rewrite_dir(&repo);
        }

        Ok(())
    } else {
        Err(format!("git rebase --abort failed: {}", String::from_utf8_lossy(&output.stderr).trim()))
    }
}

pub fn head_rewriteable_oids(repo_path: &Path) -> Result<HashSet<String>, String> {
    let repo = open_repo(repo_path)?;

    let head_commit = match repo.head() {
        Ok(h) => match h.peel_to_commit() {
            Ok(c) => c,
            Err(_) => return Ok(HashSet::new()),
        },

        Err(_) => return Ok(HashSet::new()),
    };

    let mut set = HashSet::new();
    let mut current = head_commit;

    loop {
        if current.parent_count() != 1 {
            break;
        }

        set.insert(current.id().to_string());

        current = match current.parent(0) {
            Ok(p) => p,
            Err(_) => break,
        };
    }

    Ok(set)
}

pub fn load_squash_messages(repo_path: &Path, oids: &[String]) -> Result<Vec<SquashCommitMessage>, String> {
    let repo = open_repo(repo_path)?;
    let target: HashSet<Oid> = oids
        .iter()
        .map(|s| Oid::from_str(s).map_err(git_err("Invalid OID")))
        .collect::<Result<_, _>>()?;

    let mut current = repo
        .head()
        .map_err(git_err("Failed to read HEAD"))?
        .peel_to_commit()
        .map_err(git_err("Failed to peel HEAD to commit"))?;

    let mut hits: Vec<SquashCommitMessage> = Vec::new();
    let mut remaining = target.clone();

    loop {
        if remaining.contains(&current.id()) {
            let message = current.message().unwrap_or("");

            let (subject, body) = match message.split_once("\n\n") {
                Some((s, b)) => (s.trim().to_string(), b.trim().to_string()),
                None => (message.trim().to_string(), String::new()),
            };

            hits.push(SquashCommitMessage { subject, body });
            remaining.remove(&current.id());

            if remaining.is_empty() {
                break;
            }
        }

        if current.parent_count() == 0 {
            break;
        }

        current = current.parent(0).map_err(git_err("Failed to walk to first parent"))?;
    }

    if !remaining.is_empty() {
        return Err("Some selected commits are not reachable from HEAD".to_string());
    }

    hits.reverse();

    Ok(hits)
}

fn ensure_clean(repo: &Repository, op: &str) -> Result<(), String> {
    if repo.state() != git2::RepositoryState::Clean {
        return Err(format!("Cannot {op}: another git operation is already in progress"));
    }

    if dirty_summary_repo(repo)?.has_tracked_changes {
        return Err(format!("Cannot {op}: commit or stash your changes first"));
    }

    Ok(())
}

fn parse_oids(oids: &[String]) -> Result<HashSet<Oid>, String> {
    oids.iter()
        .map(|s| Oid::from_str(s).map_err(git_err("Invalid OID")))
        .collect()
}

struct TargetWalk<'r> {
    walk: Vec<Commit<'r>>,
    oldest_target: Commit<'r>,
}

fn walk_for_targets<'r>(repo: &'r Repository, targets: &HashSet<Oid>) -> Result<TargetWalk<'r>, String> {
    if targets.is_empty() {
        return Err("walk_for_targets called with empty target set".to_string());
    }

    let head_commit = repo
        .head()
        .map_err(git_err("Failed to read HEAD"))?
        .peel_to_commit()
        .map_err(git_err("Failed to peel HEAD to commit"))?;

    let mut walk: Vec<Commit> = Vec::new();
    let mut current = head_commit;
    let mut remaining = targets.clone();

    loop {
        if remaining.contains(&current.id()) {
            remaining.remove(&current.id());
        }

        walk.push(current.clone());

        if remaining.is_empty() {
            break;
        }

        if current.parent_count() == 0 {
            break;
        }

        current = current.parent(0).map_err(git_err("Failed to walk to first parent"))?;
    }

    if !remaining.is_empty() {
        return Err("Selected commits are not all reachable from HEAD via first-parent history".to_string());
    }

    let Some(oldest_target) = walk.last().cloned() else {
        return Err("walk_for_targets: walk unexpectedly empty".to_string());
    };

    Ok(TargetWalk { walk, oldest_target })
}

fn reject_merges(walk: &[Commit<'_>], op: &str) -> Result<(), String> {
    for c in walk {
        if c.parent_count() > 1 {
            return Err(format!("Cannot {op} across merge commits"));
        }
    }

    Ok(())
}

fn map_unreachable_one(e: String) -> String {
    if e.contains("not all reachable") {
        "Selected commit is not reachable from HEAD".to_string()
    } else {
        e
    }
}

fn combined_message(chronological: &[Commit<'_>], selected: &HashSet<Oid>) -> String {
    let mut parts: Vec<String> = Vec::new();

    for c in chronological {
        if !selected.contains(&c.id()) {
            continue;
        }

        let msg = c.message().unwrap_or("").trim().to_string();

        if !msg.is_empty() {
            parts.push(msg);
        }
    }

    parts.join("\n\n")
}

fn write_temp(content: &str) -> Result<NamedTempFile, String> {
    let mut f = NamedTempFile::new().map_err(|e| format!("Failed to create temp file: {e}"))?;

    f.write_all(content.as_bytes())
        .map_err(|e| format!("Failed to write temp file: {e}"))?;

    f.flush().map_err(|e| format!("Failed to flush temp file: {e}"))?;

    Ok(f)
}

// Stash a per-commit reword message inside .git/gitte-rebase/ so it survives a conflict-pause. Returns the absolute
// path so it can be referenced from the `exec git commit --amend -F <path>` line.
fn stash_message(repo: &Repository, key: &str, content: &str) -> Result<PathBuf, String> {
    let dir = repo.path().join("gitte-rebase");
    std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create {dir:?}: {e}"))?;

    let path = dir.join(format!("reword-{key}.txt"));
    std::fs::write(&path, content).map_err(|e| format!("Failed to write {path:?}: {e}"))?;

    Ok(path)
}

fn cleanup_rewrite_dir(repo: &Repository) {
    let _ = std::fs::remove_dir_all(repo.path().join("gitte-rebase"));
}

fn reset_head_to(repo_path: &Path, git_command: &[String], target: Oid) -> Result<RebaseOutcome, String> {
    let mut cmd = super::git_command(Some(repo_path), git_command, None);
    cmd.args(["reset", "--hard"]);
    cmd.arg(target.to_string());

    let output = cmd.output().map_err(|e| format!("Failed to spawn git: {e}"))?;
    if !output.status.success() {
        return Err(format!("git reset failed: {}", String::from_utf8_lossy(&output.stderr).trim()));
    }

    Ok(RebaseOutcome::Completed { commits: None })
}

fn run_rebase(
    repo: &Repository,
    repo_path: &Path,
    git_command: &[String],
    onto: Oid,
    todo_path: &Path,
    squash_msg_path: Option<&Path>,
    expected_commits: Option<usize>,
) -> Result<RebaseOutcome, String> {
    let mut cmd = super::git_command(Some(repo_path), git_command, None);

    let cp = which_cp();
    let sequence_editor = format!("{} {}", shell_words::quote(&cp), shell_words::quote(&todo_path.to_string_lossy()));
    cmd.env("GIT_SEQUENCE_EDITOR", sequence_editor);

    if let Some(msg) = squash_msg_path {
        let editor = format!("{} {}", shell_words::quote(&cp), shell_words::quote(&msg.to_string_lossy()));
        cmd.env("GIT_EDITOR", editor);
    } else {
        cmd.env("GIT_EDITOR", "true");
    }

    cmd.args(["rebase", "-i", &onto.to_string()]);

    let output = cmd.output().map_err(|e| format!("Failed to spawn git: {e}"))?;
    classify_outcome(repo, &output, expected_commits)
}

// For most distros and the Flatpak runtime `cp` is at `/usr/bin/cp`; if not, fall back to the bare name and trust PATH.
fn which_cp() -> String {
    for candidate in ["/usr/bin/cp", "/bin/cp"] {
        if Path::new(candidate).exists() {
            return candidate.to_string();
        }
    }

    "cp".to_string()
}

fn classify_outcome(
    repo: &Repository,
    output: &std::process::Output,
    expected_commits: Option<usize>,
) -> Result<RebaseOutcome, String> {
    let state = repo.state();

    if state == git2::RepositoryState::Clean && output.status.success() {
        cleanup_rewrite_dir(repo);

        return Ok(RebaseOutcome::Completed {
            commits: expected_commits,
        });
    }

    if matches!(
        state,
        git2::RepositoryState::Rebase | git2::RepositoryState::RebaseInteractive | git2::RepositoryState::RebaseMerge
    ) {
        let has_conflicts = repo
            .index()
            .map(|mut i| {
                let _ = i.read(true);
                i.has_conflicts()
            })
            .unwrap_or(true);
        return Ok(RebaseOutcome::Paused { has_conflicts });
    }

    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let detail = if !stderr.is_empty() { stderr } else { stdout };

    if detail.is_empty() {
        Err("git rebase failed".to_string())
    } else {
        Err(format!("git rebase failed: {detail}"))
    }
}
