/* git/todo/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 std::collections::VecDeque;

use super::super::test_helpers::*;
use super::*;

fn make_context(target_branch_ref: &str, source_ref: &str, original_target_oid: &str, kind: MergeKind) -> TodoContext {
    TodoContext {
        original_head_ref: target_branch_ref.to_string(),
        source_ref: source_ref.to_string(),
        commits: 1,
        target_branch_ref: target_branch_ref.to_string(),
        original_target_oid: original_target_oid.to_string(),
        merge_kind: kind,
        last_rebased_oid: None,
    }
}

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

    r
}

#[test]
fn write_then_read_roundtrip_covers_all_op_variants() {
    let r = empty_repo();

    let ops: VecDeque<Op> = vec![
        Op::Switch {
            branch_ref: "refs/heads/feature".to_string(),
        },
        Op::SwitchDetached {
            oid: "0000000000000000000000000000000000000001".to_string(),
        },
        Op::Rebase {
            upstream: "0000000000000000000000000000000000000002".to_string(),
            original_head_oid: "0000000000000000000000000000000000000003".to_string(),
            source_branch_ref: Some("refs/heads/feature".to_string()),
        },
        Op::MergeFf {
            source: MergeSource::BranchRef {
                name: "refs/heads/feature".to_string(),
            },
        },
        Op::MergeNoFf {
            source: MergeSource::RebasedTip,
        },
    ]
    .into();

    let todo = Todo {
        schema_version: SCHEMA_VERSION,
        ops,
        context: TodoContext {
            original_head_ref: "refs/heads/main".to_string(),
            source_ref: "feature".to_string(),
            commits: 7,
            target_branch_ref: "refs/heads/main".to_string(),
            original_target_oid: "0000000000000000000000000000000000000004".to_string(),
            merge_kind: MergeKind::MergeCommit,
            last_rebased_oid: Some("0000000000000000000000000000000000000005".to_string()),
        },
    };

    write(&r.repo, &todo).unwrap();
    let back = read(&r.repo).expect("read should succeed");

    assert_eq!(back.schema_version, SCHEMA_VERSION);
    assert_eq!(back.ops.len(), 5);
    assert!(matches!(back.ops[0], Op::Switch { ref branch_ref } if branch_ref == "refs/heads/feature"));
    assert!(matches!(back.ops[1], Op::SwitchDetached { .. }));

    assert!(matches!(
        back.ops[2],
        Op::Rebase {
            source_branch_ref: Some(_),
            ..
        }
    ));

    assert!(matches!(
        back.ops[3],
        Op::MergeFf {
            source: MergeSource::BranchRef { .. }
        }
    ));

    assert!(matches!(
        back.ops[4],
        Op::MergeNoFf {
            source: MergeSource::RebasedTip
        }
    ));

    assert_eq!(back.context.commits, 7);
    assert_eq!(back.context.merge_kind, MergeKind::MergeCommit);
    assert_eq!(back.context.last_rebased_oid.as_deref(), Some("0000000000000000000000000000000000000005"));
}

#[test]
fn read_returns_none_when_no_file() {
    let r = empty_repo();
    assert!(read(&r.repo).is_none());
}

#[test]
fn read_returns_none_on_corrupt_json() {
    let r = empty_repo();
    std::fs::write(r.repo.path().join(TODO_FILE), "{ this is not json").unwrap();
    assert!(read(&r.repo).is_none());
}

#[test]
fn delete_is_idempotent() {
    let r = empty_repo();
    delete(&r.repo);

    let todo = Todo {
        schema_version: SCHEMA_VERSION,
        ops: VecDeque::new(),
        context: make_context("refs/heads/main", "feature", "0".repeat(40).as_str(), MergeKind::FastForward),
    };

    write(&r.repo, &todo).unwrap();
    delete(&r.repo);
    delete(&r.repo);

    assert!(read(&r.repo).is_none());
}

#[test]
fn pending_source_ref_returns_value_or_none() {
    let r = empty_repo();
    assert!(pending_source_ref(&r.path).is_none());

    let todo = Todo {
        schema_version: SCHEMA_VERSION,
        ops: VecDeque::new(),
        context: make_context("refs/heads/main", "feature", &"0".repeat(40), MergeKind::FastForward),
    };

    write(&r.repo, &todo).unwrap();
    assert_eq!(pending_source_ref(&r.path).as_deref(), Some("feature"));
}

#[test]
fn discard_if_present_works_with_or_without_file() {
    let r = empty_repo();
    discard_if_present(&r.path); // no-op when absent

    let todo = Todo {
        schema_version: SCHEMA_VERSION,
        ops: VecDeque::new(),
        context: make_context("refs/heads/main", "feature", &"0".repeat(40), MergeKind::FastForward),
    };

    write(&r.repo, &todo).unwrap();
    assert!(read(&r.repo).is_some());

    discard_if_present(&r.path);
    assert!(read(&r.repo).is_none());
}

#[test]
fn reconcile_no_todo_returns_no_todo() {
    let r = empty_repo();
    let outcome = reconcile(&r.path).unwrap();

    assert!(matches!(outcome, TodoOutcome::NoTodo), "got {outcome:?}");
}

#[test]
fn reconcile_empty_ops_returns_done_rebased_for_ff_kind() {
    let r = empty_repo();
    let head = r.head_oid();

    let todo = Todo {
        schema_version: SCHEMA_VERSION,
        ops: VecDeque::new(),
        context: TodoContext {
            commits: 3,
            ..make_context("refs/heads/main", "feature", &s(head), MergeKind::FastForward)
        },
    };

    write(&r.repo, &todo).unwrap();

    let outcome = reconcile(&r.path).unwrap();

    match outcome {
        TodoOutcome::Done { merge, source_ref } => {
            assert_eq!(source_ref, "feature");
            assert!(matches!(merge, super::super::types::MergeOutcome::Rebased { commits: 3 }));
        }

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

    assert!(read(&r.repo).is_none(), "todo file should be deleted after Done");
}

#[test]
fn reconcile_empty_ops_returns_done_merged_for_merge_commit_kind() {
    let r = empty_repo();
    let head = r.head_oid();

    let todo = Todo {
        schema_version: SCHEMA_VERSION,
        ops: VecDeque::new(),
        context: TodoContext {
            commits: 2,
            ..make_context("refs/heads/main", "feature", &s(head), MergeKind::MergeCommit)
        },
    };

    write(&r.repo, &todo).unwrap();

    let outcome = reconcile(&r.path).unwrap();

    match outcome {
        TodoOutcome::Done { merge, .. } => {
            assert!(matches!(merge, super::super::types::MergeOutcome::Merged { commits: 2 }));
        }

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

#[test]
fn reconcile_rebase_clean_state_with_unmoved_source_tip_returns_stuck() {
    let r = empty_repo();
    let base = r.head_oid();
    r.create_branch_at("feature", base);

    let todo = Todo {
        schema_version: SCHEMA_VERSION,
        ops: vec![Op::Rebase {
            upstream: s(base),
            original_head_oid: s(base),
            source_branch_ref: Some("refs/heads/feature".to_string()),
        }]
        .into(),
        context: make_context("refs/heads/main", "feature", &s(base), MergeKind::FastForward),
    };

    write(&r.repo, &todo).unwrap();

    let outcome = reconcile(&r.path).unwrap();
    assert!(matches!(outcome, TodoOutcome::Stuck { .. }), "got {outcome:?}");
    assert!(read(&r.repo).is_some(), "todo must remain on Stuck");
}

#[test]
fn reconcile_rebase_clean_state_with_moved_source_tip_pops_to_done() {
    let r = empty_repo();
    let base = r.head_oid();

    r.create_branch_at("feature", base);
    r.set_head_to_branch("feature");

    let feature_tip = r.commit("feature work", "f.txt", "F");
    r.set_head_to_branch("main");

    assert_ne!(base, feature_tip, "feature must point past base");

    let todo = Todo {
        schema_version: SCHEMA_VERSION,
        ops: vec![Op::Rebase {
            upstream: s(base),
            original_head_oid: s(base),
            source_branch_ref: Some("refs/heads/feature".to_string()),
        }]
        .into(),
        context: TodoContext {
            commits: 1,
            ..make_context("refs/heads/main", "feature", &s(base), MergeKind::FastForward)
        },
    };

    write(&r.repo, &todo).unwrap();

    let outcome = reconcile(&r.path).unwrap();

    match outcome {
        TodoOutcome::Done { merge, source_ref } => {
            assert_eq!(source_ref, "feature");
            assert!(matches!(merge, super::super::types::MergeOutcome::Rebased { commits: 1 }));
        }

        other => panic!("expected Done after auto-popping moved Rebase op, got {other:?}"),
    }

    assert!(read(&r.repo).is_none());
}

#[test]
fn reconcile_executes_idempotent_switch_to_completion() {
    let r = empty_repo();
    let base = r.head_oid();
    r.create_branch_at("feature", base);

    let todo = Todo {
        schema_version: SCHEMA_VERSION,
        ops: vec![Op::Switch {
            branch_ref: "refs/heads/feature".to_string(),
        }]
        .into(),
        context: make_context("refs/heads/main", "feature", &s(base), MergeKind::FastForward),
    };

    write(&r.repo, &todo).unwrap();

    let outcome = reconcile(&r.path).unwrap();
    assert!(matches!(outcome, TodoOutcome::Done { .. }), "got {outcome:?}");
    assert_eq!(r.repo.head().unwrap().name(), Some("refs/heads/feature"), "HEAD should now be on feature");
    assert!(read(&r.repo).is_none());
}

#[test]
fn reconcile_skips_already_done_switch_op() {
    let r = empty_repo();
    let base = r.head_oid();
    r.create_branch_at("feature", base);
    r.set_head_to_branch("feature");

    let todo = Todo {
        schema_version: SCHEMA_VERSION,
        ops: vec![Op::Switch {
            branch_ref: "refs/heads/feature".to_string(),
        }]
        .into(),
        context: make_context("refs/heads/main", "feature", &s(base), MergeKind::FastForward),
    };

    write(&r.repo, &todo).unwrap();

    let outcome = reconcile(&r.path).unwrap();
    assert!(matches!(outcome, TodoOutcome::Done { .. }), "got {outcome:?}");
    assert_eq!(r.repo.head().unwrap().name(), Some("refs/heads/feature"), "HEAD must still be on feature");
}

#[test]
fn abort_without_todo_is_noop() {
    let r = empty_repo();
    abort(&r.path, &gc()).unwrap();
    assert!(read(&r.repo).is_none());
}

#[test]
fn abort_in_clean_state_reattaches_head_and_drops_file() {
    let r = empty_repo();
    let main_tip = r.head_oid();

    r.create_branch_at("feature", main_tip);
    r.set_head_to_branch("feature");
    r.commit("feature work", "f.txt", "F");

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

    let todo = Todo {
        schema_version: SCHEMA_VERSION,
        ops: vec![Op::Switch {
            branch_ref: "refs/heads/main".to_string(),
        }]
        .into(),
        context: make_context("refs/heads/main", "feature", &s(main_tip), MergeKind::FastForward),
    };

    write(&r.repo, &todo).unwrap();

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

    assert!(read(&r.repo).is_none(), "todo file removed");
    assert_eq!(r.repo.head().unwrap().name(), Some("refs/heads/main"), "HEAD reattached to original_head_ref");
}
