/* git/status/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::fs;
use std::path::Path;

use tempfile::TempDir;

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

fn delete_file_fs(repo: &TestRepo, path: &str) {
    fs::remove_file(repo.path.join(path)).unwrap();
}

fn stage_path_directly(repo: &TestRepo, path: &str) {
    let mut index = repo.repo.index().unwrap();
    index.add_path(Path::new(path)).unwrap();
    index.write().unwrap();
}

fn find_changed<'a>(files: &'a [ChangedFile], path: &str) -> &'a ChangedFile {
    files
        .iter()
        .find(|f| f.path == path)
        .unwrap_or_else(|| panic!("expected status entry for {path}, got {files:?}"))
}

fn index_has_path(repo: &TestRepo, path: &str) -> bool {
    let mut idx = repo.repo.index().unwrap();
    idx.read(true).unwrap();
    idx.get_path(Path::new(path), 0).is_some()
}

#[test]
fn quick_status_invalid_path_returns_invalid() {
    let tmp = TempDir::new().unwrap();

    let qs = quick_status(tmp.path());

    assert!(!qs.valid);
    assert!(qs.branch.is_none());
    assert!(!qs.dirty);
}

#[test]
fn quick_status_clean_repo() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "hello\n");

    let qs = quick_status(&r.path);

    assert!(qs.valid);
    assert_eq!(qs.branch.as_deref(), Some("main"));
    assert!(!qs.dirty);
}

#[test]
fn quick_status_dirty_with_untracked() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "hello\n");
    r.write_file("new.txt", "new\n");

    let qs = quick_status(&r.path);

    assert!(qs.valid);
    assert!(qs.dirty);
}

#[test]
fn quick_status_dirty_with_modified() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("a.txt", "v2\n");

    let qs = quick_status(&r.path);

    assert!(qs.dirty);
}

#[test]
fn quick_status_bare_repo() {
    let (_tmp, path) = bare_repo();

    let qs = quick_status(&path);

    assert!(qs.valid);
    assert!(qs.branch.is_none());
    assert!(!qs.dirty);
}

#[test]
fn dirty_summary_clean_repo() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");

    let s = dirty_summary(&r.path).unwrap();

    assert!(!s.has_tracked_changes);
    assert!(!s.has_untracked);
    assert!(!s.is_dirty());
}

#[test]
fn dirty_summary_only_untracked() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("new.txt", "new\n");

    let s = dirty_summary(&r.path).unwrap();

    assert!(s.has_untracked);
    assert!(!s.has_tracked_changes);
}

#[test]
fn dirty_summary_only_tracked() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("a.txt", "v2\n");

    let s = dirty_summary(&r.path).unwrap();

    assert!(s.has_tracked_changes);
    assert!(!s.has_untracked);
}

#[test]
fn dirty_summary_mixed() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("a.txt", "v2\n");
    r.write_file("new.txt", "new\n");

    let s = dirty_summary(&r.path).unwrap();

    assert!(s.has_tracked_changes);
    assert!(s.has_untracked);
    assert!(s.is_dirty());
}

#[test]
fn load_status_empty_for_clean_repo() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");

    let files = load_status_from_path(&r.path).unwrap();

    assert!(files.is_empty(), "expected no changed files, got {files:?}");
}

#[test]
fn load_status_classifies_untracked_as_wt_new() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("new.txt", "new\n");

    let files = load_status_from_path(&r.path).unwrap();
    let entry = find_changed(&files, "new.txt");

    assert_eq!(entry.index_status, None);
    assert_eq!(entry.wt_status, Some(FileStatus::New));
}

#[test]
fn load_status_classifies_modified_unstaged() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("a.txt", "v2\n");

    let files = load_status_from_path(&r.path).unwrap();
    let entry = find_changed(&files, "a.txt");

    assert_eq!(entry.index_status, None);
    assert_eq!(entry.wt_status, Some(FileStatus::Modified));
}

#[test]
fn load_status_classifies_staged_new() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("new.txt", "new\n");
    stage_path_directly(&r, "new.txt");

    let files = load_status_from_path(&r.path).unwrap();
    let entry = find_changed(&files, "new.txt");

    assert_eq!(entry.index_status, Some(FileStatus::New));
    assert_eq!(entry.wt_status, None);
    assert!(entry.is_fully_staged());
}

#[test]
fn load_status_classifies_staged_and_modified() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("a.txt", "v2\n");
    stage_path_directly(&r, "a.txt");
    r.write_file("a.txt", "v3\n");

    let files = load_status_from_path(&r.path).unwrap();
    let entry = find_changed(&files, "a.txt");

    assert_eq!(entry.index_status, Some(FileStatus::Modified));
    assert_eq!(entry.wt_status, Some(FileStatus::Modified));
    assert!(!entry.is_fully_staged());
}

#[test]
fn load_status_deleted_file() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    delete_file_fs(&r, "a.txt");

    let files = load_status_from_path(&r.path).unwrap();
    let entry = find_changed(&files, "a.txt");

    assert_eq!(entry.index_status, None);
    assert_eq!(entry.wt_status, Some(FileStatus::Deleted));
}

#[test]
fn stage_file_adds_new_file_to_index() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("new.txt", "new\n");

    stage_file(&r.path, "new.txt", Some(FileStatus::New)).unwrap();

    assert!(index_has_path(&r, "new.txt"));
    let files = load_status_from_path(&r.path).unwrap();
    let entry = find_changed(&files, "new.txt");
    assert_eq!(entry.index_status, Some(FileStatus::New));
    assert_eq!(entry.wt_status, None);
}

#[test]
fn stage_file_with_deleted_status_removes_from_index() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    delete_file_fs(&r, "a.txt");

    stage_file(&r.path, "a.txt", Some(FileStatus::Deleted)).unwrap();

    assert!(!index_has_path(&r, "a.txt"));
    let files = load_status_from_path(&r.path).unwrap();
    let entry = find_changed(&files, "a.txt");
    assert_eq!(entry.index_status, Some(FileStatus::Deleted));
}

#[test]
fn stage_files_empty_input_is_noop() {
    let r = TestRepo::new();
    let result = stage_files(&r.path, &[]);
    assert!(result.is_ok());
}

#[test]
fn stage_files_handles_mixed_add_and_delete() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("new.txt", "new\n");
    delete_file_fs(&r, "a.txt");

    stage_files(
        &r.path,
        &[
            ("new.txt".to_string(), Some(FileStatus::New)),
            ("a.txt".to_string(), Some(FileStatus::Deleted)),
        ],
    )
    .unwrap();

    assert!(index_has_path(&r, "new.txt"));
    assert!(!index_has_path(&r, "a.txt"));
}

#[test]
fn unstage_file_after_initial_commit_resets_to_head() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("a.txt", "v2\n");
    stage_path_directly(&r, "a.txt");

    unstage_file(&r.path, "a.txt").unwrap();

    let files = load_status_from_path(&r.path).unwrap();
    let entry = find_changed(&files, "a.txt");
    assert_eq!(entry.index_status, None);
    assert_eq!(entry.wt_status, Some(FileStatus::Modified));
}

#[test]
fn unstage_file_no_head_removes_from_index() {
    let r = TestRepo::new();
    r.write_file("a.txt", "v1\n");
    stage_path_directly(&r, "a.txt");
    assert!(index_has_path(&r, "a.txt"));

    unstage_file(&r.path, "a.txt").unwrap();

    assert!(!index_has_path(&r, "a.txt"));
}

#[test]
fn unstage_files_empty_input_is_noop() {
    let r = TestRepo::new();
    let result = unstage_files(&r.path, &[]);
    assert!(result.is_ok());
}

#[test]
fn stage_all_stages_new_and_deleted() {
    let r = TestRepo::new();
    r.commit("initial", "tracked.txt", "v1\n");
    r.write_file("new.txt", "new\n");
    delete_file_fs(&r, "tracked.txt");

    stage_all(&r.path).unwrap();

    assert!(index_has_path(&r, "new.txt"));
    assert!(!index_has_path(&r, "tracked.txt"));
}

#[test]
fn unstage_all_with_head_resets_index_to_head() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("a.txt", "v2\n");
    r.write_file("b.txt", "b\n");
    stage_path_directly(&r, "a.txt");
    stage_path_directly(&r, "b.txt");

    unstage_all(&r.path).unwrap();

    let files = load_status_from_path(&r.path).unwrap();
    let a = find_changed(&files, "a.txt");
    let b = find_changed(&files, "b.txt");

    assert_eq!(a.index_status, None);
    assert_eq!(a.wt_status, Some(FileStatus::Modified));
    assert_eq!(b.index_status, None);
    assert_eq!(b.wt_status, Some(FileStatus::New));
}

#[test]
fn unstage_all_without_head_clears_index() {
    let r = TestRepo::new();
    r.write_file("a.txt", "v1\n");
    r.write_file("b.txt", "b\n");
    stage_path_directly(&r, "a.txt");
    stage_path_directly(&r, "b.txt");

    unstage_all(&r.path).unwrap();

    assert!(!index_has_path(&r, "a.txt"));
    assert!(!index_has_path(&r, "b.txt"));
}

#[test]
fn discard_file_restores_modified_tracked_file() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("a.txt", "v2\n");

    discard_file(&r.path, "a.txt", None).unwrap();

    assert_eq!(fs::read_to_string(r.path.join("a.txt")).unwrap(), "v1\n");
}

#[test]
fn discard_file_removes_untracked_new_file() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("new.txt", "new\n");

    discard_file(&r.path, "new.txt", None).unwrap();

    assert!(!r.path.join("new.txt").exists());
}

#[test]
fn discard_file_unstages_added_then_deletes() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("new.txt", "new\n");
    stage_path_directly(&r, "new.txt");
    assert!(index_has_path(&r, "new.txt"));

    discard_file(&r.path, "new.txt", Some(FileStatus::New)).unwrap();

    assert!(!index_has_path(&r, "new.txt"));
    assert!(!r.path.join("new.txt").exists());
}

#[test]
fn discard_files_iterates_all() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("a.txt", "v2\n");
    r.write_file("new.txt", "new\n");

    discard_files(&r.path, &[("a.txt".to_string(), None), ("new.txt".to_string(), None)]).unwrap();

    assert_eq!(fs::read_to_string(r.path.join("a.txt")).unwrap(), "v1\n");
    assert!(!r.path.join("new.txt").exists());
}

#[test]
fn delete_file_untracked_only_removes_from_fs() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("new.txt", "new\n");

    delete_file(&r.path, "new.txt", None).unwrap();

    assert!(!r.path.join("new.txt").exists());
    assert!(!index_has_path(&r, "new.txt"));
}

#[test]
fn delete_file_tracked_removes_and_stages_deletion() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");

    delete_file(&r.path, "a.txt", None).unwrap();

    assert!(!r.path.join("a.txt").exists());
    assert!(!index_has_path(&r, "a.txt"));

    let files = load_status_from_path(&r.path).unwrap();
    let entry = find_changed(&files, "a.txt");

    assert_eq!(entry.index_status, Some(FileStatus::Deleted));
}

#[test]
fn delete_file_missing_on_disk_is_ok() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    delete_file_fs(&r, "a.txt");

    delete_file(&r.path, "a.txt", None).unwrap();

    assert!(!r.path.join("a.txt").exists());
    assert!(!index_has_path(&r, "a.txt"));
}

#[test]
fn delete_files_iterates_all() {
    let r = TestRepo::new();
    r.commit("initial", "a.txt", "v1\n");
    r.write_file("new.txt", "new\n");

    delete_files(&r.path, &[("a.txt".to_string(), None), ("new.txt".to_string(), None)]).unwrap();

    assert!(!r.path.join("a.txt").exists());
    assert!(!r.path.join("new.txt").exists());
}

#[test]
fn add_to_gitignore_creates_file_when_missing() {
    let r = TestRepo::new();

    add_to_gitignore(&r.path, "target").unwrap();

    let contents = fs::read_to_string(r.path.join(".gitignore")).unwrap();
    assert_eq!(contents, "target\n");
}

#[test]
fn add_to_gitignore_appends_with_newline_when_missing() {
    let r = TestRepo::new();
    fs::write(r.path.join(".gitignore"), "build").unwrap();

    add_to_gitignore(&r.path, "target").unwrap();

    let contents = fs::read_to_string(r.path.join(".gitignore")).unwrap();
    assert_eq!(contents, "build\ntarget\n");
}

#[test]
fn add_to_gitignore_does_not_double_newline_when_present() {
    let r = TestRepo::new();
    fs::write(r.path.join(".gitignore"), "build\n").unwrap();

    add_to_gitignore(&r.path, "target").unwrap();

    let contents = fs::read_to_string(r.path.join(".gitignore")).unwrap();
    assert_eq!(contents, "build\ntarget\n");
}

#[test]
fn add_to_gitignore_preserves_existing_entries() {
    let r = TestRepo::new();
    fs::write(r.path.join(".gitignore"), "a\nb\nc\n").unwrap();

    add_to_gitignore(&r.path, "d").unwrap();

    let contents = fs::read_to_string(r.path.join(".gitignore")).unwrap();
    assert_eq!(contents, "a\nb\nc\nd\n");
}
