use gix::bstr::ByteSlice as _;
use gix::diff::tree_with_rewrites::Change;
use gix::merge::tree::{Conflict, TreatAsUnresolved};

use crate::protocol::v1::{MergeCheckResponse, MergeConflict};

/// Perform a 3-way merge check between `target` (ours) and `source` (theirs), using their
/// merge base as the common ancestor.
///
/// This is read-only: it inspects only the conflicts of the in-memory merge and never persists
/// the merged tree. The caller must have opened `repo` with [`gix::Repository::with_object_memory`]
/// so that any objects `merge_trees` writes while merging land in memory rather than on disk —
/// the mirrored repositories (reached read-only via alternates) are never mutated.
pub fn merge_check(
    repo: &gix::Repository,
    target_oid: gix::ObjectId,
    source_oid: gix::ObjectId,
) -> anyhow::Result<MergeCheckResponse> {
    let our_tree = repo.find_commit(target_oid)?.tree_id()?.detach();
    let their_tree = repo.find_commit(source_oid)?.tree_id()?.detach();

    // Common ancestor for the 3-way merge. When the histories are unrelated (no merge base),
    // fall back to the empty tree — the same behaviour as `git merge --allow-unrelated-histories`,
    // which surfaces add/add conflicts with real filenames instead of erroring out.
    let (ancestor_tree, unrelated) = match repo.merge_base(target_oid, source_oid) {
        Ok(base) => (repo.find_commit(base.detach())?.tree_id()?.detach(), false),
        Err(_) => (gix::ObjectId::empty_tree(repo.object_hash()), true),
    };

    let options = repo.tree_merge_options()?;
    let labels = gix::merge::blob::builtin_driver::text::Labels {
        ancestor: Some("ancestor".as_bytes().as_bstr()),
        current: Some("target".as_bytes().as_bstr()),
        other: Some("source".as_bytes().as_bstr()),
    };

    let outcome = repo.merge_trees(ancestor_tree, our_tree, their_tree, labels, options)?;

    // Report only genuinely-unresolved conflicts (matching git's notion), so gix's
    // auto-resolutions don't show up as false positives. The merged tree is intentionally
    // never written.
    let mut conflicts = Vec::new();
    for conflict in &outcome.conflicts {
        if !conflict.is_unresolved(TreatAsUnresolved::git()) {
            continue;
        }
        conflicts.push(MergeConflict {
            filename: conflict.ours.location().to_string(),
            reason: conflict_reason(conflict).to_string(),
        });
    }

    let is_conflicted = !conflicts.is_empty();
    let message = match (is_conflicted, unrelated) {
        (true, true) => Some(format!(
            "{} conflicting file(s); unrelated histories (no common ancestor)",
            conflicts.len()
        )),
        (true, false) => Some(format!("{} conflicting file(s)", conflicts.len())),
        (false, true) => Some("no common ancestor (unrelated histories)".to_string()),
        (false, false) => None,
    };

    Ok(MergeCheckResponse {
        is_conflicted,
        conflicts,
        message,
    })
}

/// Classify a conflict into a short, human-readable reason from the shape of the two changes.
/// Derived from the change variants rather than the resolution failure so it stays stable
/// across gix versions.
fn conflict_reason(conflict: &Conflict) -> &'static str {
    match (&conflict.ours, &conflict.theirs) {
        (Change::Deletion { .. }, _) | (_, Change::Deletion { .. }) => "modify/delete conflict",
        (Change::Addition { .. }, Change::Addition { .. }) => "add/add conflict",
        (Change::Rewrite { .. }, _) | (_, Change::Rewrite { .. }) => "rename conflict",
        _ => "content conflict",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;
    use std::process::Command;

    fn git(dir: &Path, args: &[&str]) {
        let status = Command::new("git")
            .args(args)
            .current_dir(dir)
            .env("GIT_AUTHOR_NAME", "t")
            .env("GIT_AUTHOR_EMAIL", "t@t")
            .env("GIT_COMMITTER_NAME", "t")
            .env("GIT_COMMITTER_EMAIL", "t@t")
            .status()
            .expect("run git");
        assert!(status.success(), "git {args:?} failed");
    }

    fn write(dir: &Path, name: &str, content: &str) {
        std::fs::write(dir.join(name), content).unwrap();
    }

    fn rev_parse(dir: &Path, rev: &str) -> gix::ObjectId {
        let out = Command::new("git")
            .args(["rev-parse", rev])
            .current_dir(dir)
            .output()
            .expect("rev-parse");
        let hex = String::from_utf8(out.stdout).unwrap();
        gix::ObjectId::from_hex(hex.trim().as_bytes()).unwrap()
    }

    /// Count loose object files under `<repo>/.git/objects`, ignoring `info` and `pack`.
    fn count_loose_objects(git_dir: &Path) -> usize {
        let objects = git_dir.join(".git").join("objects");
        let mut n = 0;
        for shard in std::fs::read_dir(&objects).unwrap() {
            let shard = shard.unwrap();
            let name = shard.file_name();
            if name == "info" || name == "pack" || !shard.file_type().unwrap().is_dir() {
                continue;
            }
            n += std::fs::read_dir(shard.path()).unwrap().count();
        }
        n
    }

    /// Build a repo with a shared base commit, a `main` (target/ours) tip, and a `source`
    /// (theirs) tip built by the two closures.
    fn fixture(
        target: impl FnOnce(&Path),
        source: impl FnOnce(&Path),
    ) -> (tempfile::TempDir, gix::ObjectId, gix::ObjectId) {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path();
        git(p, &["init", "-q", "-b", "main"]);
        write(p, "a.txt", "line1\nline2\nline3\n");
        write(p, "b.txt", "keep\n");
        git(p, &["add", "."]);
        git(p, &["commit", "-q", "-m", "base"]);

        git(p, &["branch", "source"]);
        // target edits on main
        target(p);
        git(p, &["add", "-A"]);
        git(p, &["commit", "-q", "-m", "target"]);
        let target_oid = rev_parse(p, "main");

        git(p, &["checkout", "-q", "source"]);
        source(p);
        git(p, &["add", "-A"]);
        git(p, &["commit", "-q", "-m", "source"]);
        let source_oid = rev_parse(p, "source");

        (dir, target_oid, source_oid)
    }

    fn open_in_memory(dir: &Path) -> gix::Repository {
        gix::open(dir).unwrap().with_object_memory()
    }

    /// Mirror of `GitMirror::open_merge_scratch`: a throwaway repo whose alternates point at the
    /// given object dirs, opened in-memory.
    fn scratch_with_alternates(object_dirs: &[std::path::PathBuf]) -> (tempfile::TempDir, gix::Repository) {
        let scratch = tempfile::tempdir().unwrap();
        gix::init_bare(scratch.path()).unwrap();
        let info = scratch.path().join("objects").join("info");
        std::fs::create_dir_all(&info).unwrap();
        let mut alternates = String::new();
        for d in object_dirs {
            alternates.push_str(&std::fs::canonicalize(d).unwrap().display().to_string());
            alternates.push('\n');
        }
        std::fs::write(info.join("alternates"), alternates).unwrap();
        let repo = gix::open(scratch.path()).unwrap().with_object_memory();
        (scratch, repo)
    }

    #[test]
    fn cross_repo_via_alternates_never_touches_mirrors() {
        // Two separate repos sharing a base commit: `a` holds the target tip, `b` the source
        // tip. This is the real gitmirror shape (target and source live in different mirrors).
        let a = tempfile::tempdir().unwrap();
        let ap = a.path();
        git(ap, &["init", "-q", "-b", "main"]);
        write(ap, "a.txt", "line1\nline2\nline3\n");
        git(ap, &["add", "."]);
        git(ap, &["commit", "-q", "-m", "base"]);
        // target edit in repo `a`
        write(ap, "a.txt", "line1\nOURS\nline3\n");
        git(ap, &["add", "-A"]);
        git(ap, &["commit", "-q", "-m", "target"]);
        let target = rev_parse(ap, "main");

        // Clone `a` → `b`, reset to the shared base, and make a conflicting source edit.
        let b = tempfile::tempdir().unwrap();
        let bp = b.path();
        git(bp, &["clone", "-q", ap.to_str().unwrap(), "."]);
        git(bp, &["checkout", "-q", "-B", "source", &target.to_string()]);
        git(bp, &["reset", "-q", "--hard", "HEAD~1"]); // back to base
        write(bp, "a.txt", "line1\nTHEIRS\nline3\n");
        git(bp, &["add", "-A"]);
        git(bp, &["commit", "-q", "-m", "source"]);
        let source = rev_parse(bp, "source");

        let a_objs = ap.join(".git").join("objects");
        let b_objs = bp.join(".git").join("objects");
        let before_a = count_loose_objects(ap);
        let before_b = count_loose_objects(bp);

        let (_scratch, repo) = scratch_with_alternates(&[a_objs, b_objs]);
        let out = merge_check(&repo, target, source).unwrap();

        assert!(out.is_conflicted);
        assert_eq!(out.conflicts.len(), 1);
        assert_eq!(out.conflicts[0].filename, "a.txt");
        // Neither mirror gained loose objects — the merge was read-only against both.
        assert_eq!(count_loose_objects(ap), before_a, "target mirror mutated");
        assert_eq!(count_loose_objects(bp), before_b, "source mirror mutated");
    }

    #[test]
    fn non_overlapping_edits_are_clean() {
        let (dir, t, s) = fixture(
            |p| write(p, "a.txt", "line1\nCHANGED\nline3\n"),
            |p| write(p, "b.txt", "keep\nappended\n"),
        );
        let repo = open_in_memory(dir.path());
        let before = count_loose_objects(dir.path());
        let out = merge_check(&repo, t, s).unwrap();
        assert!(!out.is_conflicted, "expected clean, got {out:?}");
        assert!(out.conflicts.is_empty());
        // never-touch-the-mirror: the in-memory merge writes no loose objects to disk.
        assert_eq!(count_loose_objects(dir.path()), before);
    }

    #[test]
    fn same_line_edits_conflict() {
        let (dir, t, s) = fixture(
            |p| write(p, "a.txt", "line1\nOURS\nline3\n"),
            |p| write(p, "a.txt", "line1\nTHEIRS\nline3\n"),
        );
        let repo = open_in_memory(dir.path());
        let before = count_loose_objects(dir.path());
        let out = merge_check(&repo, t, s).unwrap();
        assert!(out.is_conflicted);
        assert_eq!(out.conflicts.len(), 1);
        assert_eq!(out.conflicts[0].filename, "a.txt");
        assert_eq!(out.conflicts[0].reason, "content conflict");
        assert_eq!(count_loose_objects(dir.path()), before);
    }

    #[test]
    fn modify_delete_conflict() {
        let (dir, t, s) = fixture(
            |p| write(p, "a.txt", "line1\nOURS\nline3\n"),
            |p| std::fs::remove_file(p.join("a.txt")).unwrap(),
        );
        let repo = open_in_memory(dir.path());
        let out = merge_check(&repo, t, s).unwrap();
        assert!(out.is_conflicted);
        assert_eq!(out.conflicts[0].filename, "a.txt");
        assert_eq!(out.conflicts[0].reason, "modify/delete conflict");
    }

    #[test]
    fn source_is_ancestor_is_clean() {
        // Source is an ancestor of target (i.e. already merged / up to date) → no conflicts.
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path();
        git(p, &["init", "-q", "-b", "main"]);
        write(p, "a.txt", "line1\nline2\n");
        git(p, &["add", "."]);
        git(p, &["commit", "-q", "-m", "base"]);
        let base = rev_parse(p, "main");
        write(p, "a.txt", "line1\nline2\nline3\n");
        git(p, &["add", "-A"]);
        git(p, &["commit", "-q", "-m", "target"]);
        let target = rev_parse(p, "main");

        let repo = open_in_memory(p);
        let out = merge_check(&repo, target, base).unwrap();
        assert!(!out.is_conflicted, "expected clean, got {out:?}");
        assert!(out.conflicts.is_empty());
    }
}
