/* git/merge/tests.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 super::super::test_helpers::*;
use super::super::types::{BranchKind, MergeOutcome, MergeSettings, MergeStrategy};
use super::merge_branch;
use crate::git::todo;

fn settings(source_ref: &str, kind: BranchKind, strategy: MergeStrategy) -> MergeSettings {
    MergeSettings {
        source_ref: source_ref.to_string(),
        source_kind: kind,
        strategy,
        squash_message: None,
    }
}

fn ff_setup() -> TestRepo {
    let r = TestRepo::new();
    let base = r.commit("base", "base.txt", "0");

    r.create_branch_at("feature", base);
    r.set_head_to_branch("feature");
    r.commit("feature work", "f.txt", "F1");
    r.set_head_to_branch("main");

    r
}

fn divergent_setup() -> TestRepo {
    let r = TestRepo::new();
    let base = r.commit("base", "base.txt", "0");

    r.create_branch_at("feature", base);
    r.commit("main work", "m.txt", "M1");
    r.set_head_to_branch("feature");
    r.commit("feature work", "f.txt", "F1");
    r.set_head_to_branch("main");

    r
}

fn conflicting_setup() -> TestRepo {
    let r = TestRepo::new();
    let base = r.commit("base", "shared.txt", "base\n");

    r.create_branch_at("feature", base);
    r.commit("main change", "shared.txt", "main change\n");
    r.set_head_to_branch("feature");
    r.commit("feature change", "shared.txt", "feature change\n");
    r.set_head_to_branch("main");

    r
}

#[test]
fn already_up_to_date_when_source_is_ancestor() {
    let r = TestRepo::new();
    let base = r.commit("base", "base.txt", "0");
    r.create_branch_at("feature", base);
    r.commit("main work", "m.txt", "M1");

    let outcome =
        merge_branch(&r.path, &settings("feature", BranchKind::Local, MergeStrategy::FastForwardOrMergeCommit), &gc())
            .unwrap();

    assert!(matches!(outcome, MergeOutcome::AlreadyUpToDate), "got {outcome:?}");
}

#[test]
fn ff_or_merge_commit_uses_ff_when_possible() {
    let r = ff_setup();
    let feature_tip = r.repo.refname_to_id("refs/heads/feature").unwrap();

    let outcome =
        merge_branch(&r.path, &settings("feature", BranchKind::Local, MergeStrategy::FastForwardOrMergeCommit), &gc())
            .unwrap();

    assert!(matches!(outcome, MergeOutcome::FastForward { .. }), "got {outcome:?}");
    assert_eq!(r.head_oid(), feature_tip);

    let head = r.repo.head().unwrap().peel_to_commit().unwrap();
    assert_eq!(head.parent_count(), 1);
}

#[test]
fn ff_or_merge_commit_creates_merge_commit_on_divergence() {
    let r = divergent_setup();
    let main_tip_before = r.head_oid();
    let feature_tip = r.repo.refname_to_id("refs/heads/feature").unwrap();

    let outcome =
        merge_branch(&r.path, &settings("feature", BranchKind::Local, MergeStrategy::FastForwardOrMergeCommit), &gc())
            .unwrap();

    assert!(matches!(outcome, MergeOutcome::Merged { commits: 1 }), "got {outcome:?}");

    let head = r.repo.head().unwrap().peel_to_commit().unwrap();
    assert_eq!(head.parent_count(), 2, "should be a merge commit");
    assert_eq!(head.parent_id(0).unwrap(), main_tip_before);
    assert_eq!(head.parent_id(1).unwrap(), feature_tip);
    assert!(head.message().unwrap_or("").contains("Merge branch 'feature'"));
}

#[test]
fn merge_commit_forces_merge_even_when_ff_possible() {
    let r = ff_setup();
    let main_tip_before = r.head_oid();
    let feature_tip = r.repo.refname_to_id("refs/heads/feature").unwrap();

    let outcome =
        merge_branch(&r.path, &settings("feature", BranchKind::Local, MergeStrategy::MergeCommit), &gc()).unwrap();

    assert!(matches!(outcome, MergeOutcome::Merged { .. }), "got {outcome:?}");

    let head = r.repo.head().unwrap().peel_to_commit().unwrap();
    assert_eq!(head.parent_count(), 2, "must be a merge commit even when FF was possible");
    assert_eq!(head.parent_id(0).unwrap(), main_tip_before);
    assert_eq!(head.parent_id(1).unwrap(), feature_tip);
}

#[test]
fn ff_or_merge_commit_reports_conflicts() {
    let r = conflicting_setup();

    let outcome =
        merge_branch(&r.path, &settings("feature", BranchKind::Local, MergeStrategy::FastForwardOrMergeCommit), &gc())
            .unwrap();

    match outcome {
        MergeOutcome::MergeConflicts { conflicted_files } => {
            assert_eq!(conflicted_files, vec!["shared.txt".to_string()]);
        }

        other => panic!("expected MergeConflicts, got {other:?}"),
    }

    assert!(r.fresh_index().has_conflicts(), "index should be in conflicted state");
}

#[test]
fn rebase_fast_forward_replays_source_onto_target() {
    let r = divergent_setup();
    let main_tip_before = r.head_oid();
    let feature_tip_before = r.repo.refname_to_id("refs/heads/feature").unwrap();

    let outcome =
        merge_branch(&r.path, &settings("feature", BranchKind::Local, MergeStrategy::RebaseFastForward), &gc())
            .unwrap();

    assert!(matches!(outcome, MergeOutcome::Rebased { commits: 1 }), "got {outcome:?}");

    let head_oid_after = r.head_oid();
    let head = r.repo.head().unwrap().peel_to_commit().unwrap();
    assert_eq!(head.parent_count(), 1, "linear history, no merge commit");
    assert_eq!(head.parent_id(0).unwrap(), main_tip_before, "rebased source commit sits on top of original target tip");

    let feature_tip_after = r.repo.refname_to_id("refs/heads/feature").unwrap();
    assert_eq!(feature_tip_after, head_oid_after, "source branch advances along with target (GitHub semantics)");
    assert_ne!(feature_tip_after, feature_tip_before, "source branch must have moved");
}

#[test]
fn rebase_fast_forward_reports_conflicts() {
    let r = conflicting_setup();

    let outcome =
        merge_branch(&r.path, &settings("feature", BranchKind::Local, MergeStrategy::RebaseFastForward), &gc())
            .unwrap();

    assert!(matches!(outcome, MergeOutcome::RebaseConflicts), "got {outcome:?}");
    assert!(r.fresh_index().has_conflicts(), "index should be in conflicted state");
    assert!(todo::read(&r.repo).is_some(), "todo file should persist while rebase is paused");

    assert!(
        matches!(
            r.repo.state(),
            git2::RepositoryState::Rebase
                | git2::RepositoryState::RebaseInteractive
                | git2::RepositoryState::RebaseMerge
        ),
        "repository should be in a rebase state"
    );
}

#[test]
fn rebase_merge_commit_creates_merge_after_rebase() {
    let r = divergent_setup();
    let main_tip_before = r.head_oid();
    let feature_tip_before = r.repo.refname_to_id("refs/heads/feature").unwrap();

    let outcome =
        merge_branch(&r.path, &settings("feature", BranchKind::Local, MergeStrategy::RebaseMergeCommit), &gc())
            .unwrap();

    assert!(matches!(outcome, MergeOutcome::Merged { commits: 1 }), "got {outcome:?}");

    let head = r.repo.head().unwrap().peel_to_commit().unwrap();
    assert_eq!(head.parent_count(), 2, "should be a merge commit");
    assert_eq!(head.parent_id(0).unwrap(), main_tip_before, "first parent is original target tip");

    let feature_tip_after = r.repo.refname_to_id("refs/heads/feature").unwrap();
    assert_eq!(
        head.parent_id(1).unwrap(),
        feature_tip_after,
        "second parent of merge commit equals the rebased source tip"
    );
    assert_ne!(feature_tip_after, feature_tip_before, "source branch must have moved");
}

#[test]
fn rebase_fast_forward_clears_todo_on_success() {
    let r = divergent_setup();
    let _ = merge_branch(&r.path, &settings("feature", BranchKind::Local, MergeStrategy::RebaseFastForward), &gc())
        .unwrap();

    assert!(todo::read(&r.repo).is_none(), "todo file must be cleared after success");
    assert_eq!(r.repo.head().unwrap().name(), Some("refs/heads/main"), "HEAD should be attached to the target branch");
}

#[test]
fn squash_creates_single_commit_and_keeps_source() {
    let r = divergent_setup();
    let main_tip_before = r.head_oid();
    let feature_tip_before = r.repo.refname_to_id("refs/heads/feature").unwrap();

    let outcome = merge_branch(&r.path, &settings("feature", BranchKind::Local, MergeStrategy::Squash), &gc()).unwrap();

    assert!(matches!(outcome, MergeOutcome::Squashed { commits: 1 }), "got {outcome:?}");

    let head = r.repo.head().unwrap().peel_to_commit().unwrap();
    assert_eq!(head.parent_count(), 1, "squash creates a single-parent commit");
    assert_eq!(head.parent_id(0).unwrap(), main_tip_before);

    let feature_tip_after = r.repo.refname_to_id("refs/heads/feature").unwrap();
    assert_eq!(feature_tip_after, feature_tip_before, "source branch ref must not move");
}

#[test]
fn rebase_abort_restores_target_and_clears_todo() {
    let r = conflicting_setup();
    let main_tip_before = r.head_oid();
    let feature_tip_before = r.repo.refname_to_id("refs/heads/feature").unwrap();

    let outcome =
        merge_branch(&r.path, &settings("feature", BranchKind::Local, MergeStrategy::RebaseFastForward), &gc())
            .unwrap();
    assert!(matches!(outcome, MergeOutcome::RebaseConflicts));

    todo::abort(&r.path, &gc()).unwrap();

    assert!(todo::read(&r.repo).is_none(), "todo file must be cleared after abort");
    assert_eq!(r.repo.head().unwrap().name(), Some("refs/heads/main"), "HEAD should be reattached to target branch");
    assert_eq!(r.head_oid(), main_tip_before, "target branch unchanged after abort");
    assert_eq!(r.repo.refname_to_id("refs/heads/feature").unwrap(), feature_tip_before, "source branch unchanged");
}

#[test]
fn todo_resumes_after_external_continue() {
    let r = conflicting_setup();
    let main_tip_before = r.head_oid();

    let outcome =
        merge_branch(&r.path, &settings("feature", BranchKind::Local, MergeStrategy::RebaseFastForward), &gc())
            .unwrap();
    assert!(matches!(outcome, MergeOutcome::RebaseConflicts));

    r.write_file("shared.txt", "resolved\n");
    let mut idx = r.repo.index().unwrap();
    idx.add_path(std::path::Path::new("shared.txt")).unwrap();
    idx.write().unwrap();

    super::super::rebase::rebase_continue(&r.path, &gc()).unwrap();

    let outcome = todo::reconcile(&r.path).unwrap();
    assert!(matches!(outcome, todo::TodoOutcome::Done { .. }), "got {outcome:?}");

    assert!(todo::read(&r.repo).is_none(), "todo file gone after finalize");
    assert_eq!(r.repo.head().unwrap().name(), Some("refs/heads/main"), "HEAD reattached to target branch");
    assert_ne!(r.head_oid(), main_tip_before, "target branch advanced");

    assert_eq!(
        r.repo.refname_to_id("refs/heads/feature").unwrap(),
        r.head_oid(),
        "source branch advanced to the same tip as target"
    );
}

#[test]
fn todo_stuck_after_external_abort() {
    let r = conflicting_setup();
    let main_tip_before = r.head_oid();
    let feature_tip_before = r.repo.refname_to_id("refs/heads/feature").unwrap();

    let outcome =
        merge_branch(&r.path, &settings("feature", BranchKind::Local, MergeStrategy::RebaseFastForward), &gc())
            .unwrap();
    assert!(matches!(outcome, MergeOutcome::RebaseConflicts));

    let status = std::process::Command::new("git")
        .arg("-C")
        .arg(&r.path)
        .args(["rebase", "--abort"])
        .status()
        .unwrap();
    assert!(status.success());

    let outcome = todo::reconcile(&r.path).unwrap();
    assert!(matches!(outcome, todo::TodoOutcome::Stuck { .. }), "got {outcome:?}");
    assert!(todo::read(&r.repo).is_some(), "todo file should still exist after Stuck");

    todo::abort(&r.path, &gc()).unwrap();
    assert!(todo::read(&r.repo).is_none(), "todo file cleared after abort");
    assert_eq!(r.repo.head().unwrap().name(), Some("refs/heads/main"), "HEAD reattached to target branch");
    assert_eq!(r.head_oid(), main_tip_before, "target branch unchanged");
    assert_eq!(r.repo.refname_to_id("refs/heads/feature").unwrap(), feature_tip_before, "source branch unchanged");
}

#[test]
fn schema_mismatch_todo_is_ignored() {
    let r = ff_setup();
    let path = r.repo.path().join("gitte-todos.json");
    std::fs::write(&path, r#"{"schema_version": 999, "ops": [], "context": {}}"#).unwrap();

    assert!(todo::read(&r.repo).is_none(), "schema-mismatch todo should be treated as absent");

    let outcome = todo::reconcile(&r.path).unwrap();
    assert!(matches!(outcome, todo::TodoOutcome::NoTodo), "got {outcome:?}");
}

#[test]
fn merges_from_remote_branch() {
    let r = ff_setup();
    let feature_tip = r.repo.refname_to_id("refs/heads/feature").unwrap();

    r.repo
        .reference("refs/remotes/origin/feature", feature_tip, false, "test setup")
        .unwrap();

    let outcome = merge_branch(
        &r.path,
        &settings("origin/feature", BranchKind::Remote, MergeStrategy::FastForwardOrMergeCommit),
        &gc(),
    )
    .unwrap();

    assert!(matches!(outcome, MergeOutcome::FastForward { .. }), "got {outcome:?}");
    assert_eq!(r.head_oid(), feature_tip);
}

#[test]
fn detached_head_is_rejected() {
    let r = ff_setup();
    let head_oid = r.head_oid();

    r.repo.set_head_detached(head_oid).unwrap();

    let err =
        merge_branch(&r.path, &settings("feature", BranchKind::Local, MergeStrategy::FastForwardOrMergeCommit), &gc())
            .expect_err("merge from detached HEAD must fail");

    assert!(err.contains("detached"), "unexpected error: {err}");
}

#[test]
fn unknown_source_ref_is_rejected() {
    let r = ff_setup();

    let err = merge_branch(
        &r.path,
        &settings("does-not-exist", BranchKind::Local, MergeStrategy::FastForwardOrMergeCommit),
        &gc(),
    )
    .expect_err("missing source ref must fail");

    assert!(
        err.to_lowercase().contains("resolve") || err.to_lowercase().contains("not found"),
        "unexpected error: {err}"
    );
}
