//! End-to-end conflict and merge tests.
//!
//! Each test spins up a bare git remote and two independent "machines"
//! (separate workspaces, data dirs, SyncorPaths). No mocking — real
//! git repos, real chkpt-core packs, real filesystem operations.

use std::fs;
use syncor_core::config::SyncorPaths;
use syncor_core::link::{LinkId, LinkInfo, LinkMode};
use syncor_core::sync::engine::SyncEngine;
use syncor_core::sync::state::StateDb;
use syncor_core::transport::git::GitTransport;
use syncor_core::transport::SyncTransport;
use tempfile::TempDir;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

struct Machine {
    workspace: TempDir,
    data: TempDir,
    link: LinkInfo,
    paths: SyncorPaths,
}

impl Machine {
    fn engine(&self) -> SyncEngine {
        let transport = GitTransport::new(self.paths.clone());
        SyncEngine::new(self.paths.clone(), Box::new(transport))
    }

    fn state_db(&self) -> StateDb {
        StateDb::open(self.paths.link_state_db()).unwrap()
    }

    fn write(&self, path: &str, content: &str) {
        let full = self.workspace.path().join(path);
        if let Some(parent) = full.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(full, content).unwrap();
    }

    fn read(&self, path: &str) -> String {
        fs::read_to_string(self.workspace.path().join(path)).unwrap()
    }

    fn exists(&self, path: &str) -> bool {
        self.workspace.path().join(path).exists()
    }

    fn delete(&self, path: &str) {
        fs::remove_file(self.workspace.path().join(path)).unwrap();
    }
}

/// Create a bare remote and two machines pointing at it.
fn two_machines(link_name: &str) -> (TempDir, Machine, Machine) {
    let remote = TempDir::new().unwrap();
    git2::Repository::init_bare(remote.path()).unwrap();
    let url = remote.path().to_str().unwrap().to_string();

    let mk = |mode: LinkMode| -> Machine {
        let workspace = TempDir::new().unwrap();
        let data = TempDir::new().unwrap();
        let link = LinkInfo {
            id: LinkId::from_parts(&url, link_name),
            name: link_name.to_string(),
            repo: url.clone(),
            local_dir: workspace.path().to_path_buf(),
            mode,
            poll_interval_secs: None,
        };
        let paths = SyncorPaths::with_home(data.path());
        Machine {
            workspace,
            data,
            link,
            paths,
        }
    };

    let a = mk(LinkMode::Push);
    let b = mk(LinkMode::Pull);
    (remote, a, b)
}

/// Machine A pushes, Machine B connects and restores initial state.
fn bootstrap(a: &Machine, b: &Machine) {
    let ea = a.engine();
    ea.init_link(&a.link).unwrap();
    ea.push(&a.link).unwrap();

    let eb = b.engine();
    eb.init_link(&b.link).unwrap();
    eb.restore_latest(&b.link).unwrap();
}

// ---------------------------------------------------------------------------
// Conflict detection tests
// ---------------------------------------------------------------------------

/// Both machines modify the same file with different content → conflict.
#[test]
fn conflict_both_modify_same_file() {
    let (_remote, a, b) = two_machines("conflict-both-mod");

    // Initial state: shared file
    a.write("config.yaml", "key: original");
    bootstrap(&a, &b);
    assert_eq!(b.read("config.yaml"), "key: original");

    // A modifies and pushes
    a.write("config.yaml", "key: from-machine-a");
    a.engine().push(&a.link).unwrap();

    // B modifies locally (different content) then pulls
    b.write("config.yaml", "key: from-machine-b");
    let result = b.engine().pull(&b.link);

    // Should fail with conflict
    assert!(result.is_err(), "pull should return conflict error");
    let err = result.unwrap_err().to_string();
    assert!(
        err.contains("conflict"),
        "error should mention conflict: {}",
        err
    );

    // Conflict should be recorded in state.db
    let db = b.state_db();
    let conflicts = db.list_conflicts(b.link.id.as_str()).unwrap();
    assert_eq!(conflicts.len(), 1);
    assert_eq!(conflicts[0].file_path, "config.yaml");
    assert!(conflicts[0].local_hash.is_some());
    assert!(conflicts[0].remote_hash.is_some());

    // B's local file should be UNTOUCHED (not overwritten)
    assert_eq!(b.read("config.yaml"), "key: from-machine-b");
}

/// A modifies file1, B modifies file2 → no conflict, auto-merge.
#[test]
fn merge_non_overlapping_changes() {
    let (_remote, a, b) = two_machines("merge-no-overlap");

    a.write("file1.txt", "original1");
    a.write("file2.txt", "original2");
    bootstrap(&a, &b);

    // A modifies file1 and pushes
    a.write("file1.txt", "modified-by-a");
    a.engine().push(&a.link).unwrap();

    // B has not changed anything locally → pull should auto-apply
    let result = b.engine().pull(&b.link).unwrap();
    assert!(result.restored);
    assert_eq!(b.read("file1.txt"), "modified-by-a");
    assert_eq!(b.read("file2.txt"), "original2");
}

/// A modifies a file, B doesn't change it → remote change auto-applied.
#[test]
fn auto_apply_remote_change() {
    let (_remote, a, b) = two_machines("auto-apply");

    a.write("data.txt", "v1");
    bootstrap(&a, &b);

    a.write("data.txt", "v2");
    a.engine().push(&a.link).unwrap();

    let result = b.engine().pull(&b.link).unwrap();
    assert!(result.restored);
    assert_eq!(b.read("data.txt"), "v2");
}

/// B modifies a file locally, A doesn't change it → B's local change is kept.
#[test]
fn keep_local_change_when_remote_unchanged() {
    let (_remote, a, b) = two_machines("keep-local");

    a.write("data.txt", "original");
    bootstrap(&a, &b);

    // A pushes with NO changes (just to have a remote revision)
    a.engine().push(&a.link).unwrap();

    // B modifies locally, then pulls
    b.write("data.txt", "local-edit");
    let result = b.engine().pull(&b.link).unwrap();

    // No remote file changes → nothing to apply
    // B's local change should be preserved
    assert_eq!(b.read("data.txt"), "local-edit");
}

/// Both machines add the SAME file with DIFFERENT content → conflict.
#[test]
fn conflict_both_add_same_new_file() {
    let (_remote, a, b) = two_machines("conflict-both-add");

    a.write("base.txt", "base");
    bootstrap(&a, &b);

    // A adds new.txt and pushes
    a.write("new.txt", "from-a");
    a.engine().push(&a.link).unwrap();

    // B also adds new.txt locally with different content
    b.write("new.txt", "from-b");
    let result = b.engine().pull(&b.link);

    assert!(result.is_err());
    let db = b.state_db();
    let conflicts = db.list_conflicts(b.link.id.as_str()).unwrap();
    assert!(
        conflicts.iter().any(|c| c.file_path == "new.txt"),
        "new.txt should be in conflicts"
    );

    // B's version preserved
    assert_eq!(b.read("new.txt"), "from-b");
}

/// A deletes a file, B modifies it → conflict.
#[test]
fn conflict_remote_delete_local_modify() {
    let (_remote, a, b) = two_machines("conflict-del-mod");

    a.write("important.txt", "original");
    bootstrap(&a, &b);

    // A deletes and pushes
    a.delete("important.txt");
    a.engine().push(&a.link).unwrap();

    // B modifies the same file
    b.write("important.txt", "b-edited");
    let result = b.engine().pull(&b.link);

    assert!(result.is_err());
    let db = b.state_db();
    let conflicts = db.list_conflicts(b.link.id.as_str()).unwrap();
    assert!(
        conflicts.iter().any(|c| c.file_path == "important.txt"),
        "should conflict on deleted-vs-modified"
    );

    // B's modified version should still exist
    assert_eq!(b.read("important.txt"), "b-edited");
}

/// A modifies, B deletes → conflict.
#[test]
fn conflict_remote_modify_local_delete() {
    let (_remote, a, b) = two_machines("conflict-mod-del");

    a.write("shared.txt", "original");
    bootstrap(&a, &b);

    // A modifies and pushes
    a.write("shared.txt", "a-modified");
    a.engine().push(&a.link).unwrap();

    // B deletes locally
    b.delete("shared.txt");
    let result = b.engine().pull(&b.link);

    assert!(result.is_err());
    let db = b.state_db();
    let conflicts = db.list_conflicts(b.link.id.as_str()).unwrap();
    assert!(
        conflicts.iter().any(|c| c.file_path == "shared.txt"),
        "should conflict on modified-vs-deleted"
    );
}

/// Both modify to the SAME content → no conflict.
#[test]
fn no_conflict_when_both_change_to_same() {
    let (_remote, a, b) = two_machines("same-change");

    a.write("data.txt", "original");
    bootstrap(&a, &b);

    // Both change to identical content
    a.write("data.txt", "converged");
    a.engine().push(&a.link).unwrap();

    b.write("data.txt", "converged");
    let result = b.engine().pull(&b.link);

    // Should NOT conflict
    assert!(result.is_ok(), "identical changes should not conflict");
    assert_eq!(b.read("data.txt"), "converged");
}

/// A adds a new file, B has no changes → new file auto-applied.
#[test]
fn auto_apply_remote_add() {
    let (_remote, a, b) = two_machines("auto-add");

    a.write("base.txt", "base");
    bootstrap(&a, &b);

    // A adds a new file
    a.write("extra.txt", "new content");
    a.engine().push(&a.link).unwrap();

    let result = b.engine().pull(&b.link).unwrap();
    assert!(result.restored);
    assert!(b.exists("extra.txt"));
    assert_eq!(b.read("extra.txt"), "new content");
}

/// A deletes a file, B has no local changes → file removed on B.
#[test]
fn auto_apply_remote_delete() {
    let (_remote, a, b) = two_machines("auto-delete");

    a.write("file1.txt", "keep");
    a.write("file2.txt", "will-delete");
    bootstrap(&a, &b);
    assert!(b.exists("file2.txt"));

    // A deletes file2
    a.delete("file2.txt");
    a.engine().push(&a.link).unwrap();

    let result = b.engine().pull(&b.link).unwrap();
    assert!(result.restored);
    assert!(b.exists("file1.txt"));
    assert!(!b.exists("file2.txt"), "deleted file should be removed");
}

/// Both delete the same file → no conflict.
#[test]
fn no_conflict_both_delete() {
    let (_remote, a, b) = two_machines("both-delete");

    a.write("temp.txt", "temporary");
    bootstrap(&a, &b);

    a.delete("temp.txt");
    a.engine().push(&a.link).unwrap();

    b.delete("temp.txt");
    let result = b.engine().pull(&b.link);

    assert!(result.is_ok(), "both deleting should not conflict");
    assert!(!b.exists("temp.txt"));
}

// ---------------------------------------------------------------------------
// Conflict resolution flow
// ---------------------------------------------------------------------------

/// After conflict, clearing conflicts allows the next pull to proceed.
#[test]
fn conflict_resolution_unblocks_sync() {
    let (_remote, a, b) = two_machines("resolve-unblock");

    a.write("config.txt", "original");
    bootstrap(&a, &b);

    // Create conflict
    a.write("config.txt", "from-a");
    a.engine().push(&a.link).unwrap();
    b.write("config.txt", "from-b");
    let _ = b.engine().pull(&b.link); // conflict

    // Verify sync is blocked (has conflicts)
    let db = b.state_db();
    assert!(db.has_conflicts(b.link.id.as_str()).unwrap());

    // Simulate resolution: user chooses to keep remote
    // Write remote content manually (like resolve command would)
    b.write("config.txt", "from-a");
    db.clear_conflicts(b.link.id.as_str()).unwrap();
    assert!(!db.has_conflicts(b.link.id.as_str()).unwrap());

    // Now A pushes another change
    a.write("config.txt", "from-a-v2");
    a.engine().push(&a.link).unwrap();

    // B should be able to pull again
    // But first B needs to push its resolved state so the base snapshot is updated
    // Actually, let's just verify conflicts are cleared
    let conflicts = db.list_conflicts(b.link.id.as_str()).unwrap();
    assert!(conflicts.is_empty(), "conflicts should be cleared");
}

// ---------------------------------------------------------------------------
// Multi-file mixed scenario
// ---------------------------------------------------------------------------

/// Complex scenario: A modifies f1, adds f3, deletes f4.
/// B modifies f2 locally. Pull should auto-merge without conflict.
#[test]
fn complex_non_conflicting_merge() {
    let (_remote, a, b) = two_machines("complex-merge");

    a.write("f1.txt", "original1");
    a.write("f2.txt", "original2");
    a.write("f4.txt", "will-delete");
    bootstrap(&a, &b);

    // A: modify f1, add f3, delete f4
    a.write("f1.txt", "a-modified-f1");
    a.write("f3.txt", "new-file-from-a");
    a.delete("f4.txt");
    a.engine().push(&a.link).unwrap();

    // B: only modified f2 locally (no overlap with A's changes)
    b.write("f2.txt", "b-modified-f2");

    let result = b.engine().pull(&b.link);
    assert!(
        result.is_ok(),
        "non-overlapping changes should merge cleanly"
    );

    let result = result.unwrap();
    assert!(result.restored);

    // Verify merged state
    assert_eq!(b.read("f1.txt"), "a-modified-f1"); // A's change applied
    assert_eq!(b.read("f2.txt"), "b-modified-f2"); // B's local kept
    assert!(b.exists("f3.txt")); // A's new file added
    assert_eq!(b.read("f3.txt"), "new-file-from-a");
    assert!(!b.exists("f4.txt")); // A's delete applied
}

/// Multiple conflicts in one pull.
#[test]
fn multiple_conflicts_at_once() {
    let (_remote, a, b) = two_machines("multi-conflict");

    a.write("a.txt", "orig-a");
    a.write("b.txt", "orig-b");
    a.write("c.txt", "orig-c");
    bootstrap(&a, &b);

    // A modifies all three
    a.write("a.txt", "a-v2");
    a.write("b.txt", "b-v2");
    a.write("c.txt", "c-v2");
    a.engine().push(&a.link).unwrap();

    // B also modifies all three differently
    b.write("a.txt", "a-local");
    b.write("b.txt", "b-local");
    b.write("c.txt", "c-local");
    let result = b.engine().pull(&b.link);

    assert!(result.is_err());
    let db = b.state_db();
    let conflicts = db.list_conflicts(b.link.id.as_str()).unwrap();
    assert_eq!(conflicts.len(), 3, "should have 3 conflicts");

    let paths: Vec<&str> = conflicts.iter().map(|c| c.file_path.as_str()).collect();
    assert!(paths.contains(&"a.txt"));
    assert!(paths.contains(&"b.txt"));
    assert!(paths.contains(&"c.txt"));
}

// ---------------------------------------------------------------------------
// Sequential sync rounds
// ---------------------------------------------------------------------------

/// Multiple push/pull rounds without conflict.
#[test]
fn sequential_sync_rounds() {
    let (_remote, a, b) = two_machines("seq-rounds");

    // Round 1
    a.write("file.txt", "round1");
    bootstrap(&a, &b);
    assert_eq!(b.read("file.txt"), "round1");

    // Round 2: A modifies, pushes, B pulls
    a.write("file.txt", "round2");
    a.engine().push(&a.link).unwrap();
    b.engine().pull(&b.link).unwrap();
    assert_eq!(b.read("file.txt"), "round2");

    // Round 3: A adds file, pushes, B pulls
    a.write("new.txt", "round3");
    a.engine().push(&a.link).unwrap();
    b.engine().pull(&b.link).unwrap();
    assert_eq!(b.read("new.txt"), "round3");
    assert_eq!(b.read("file.txt"), "round2"); // unchanged

    // Round 4: A deletes, pushes, B pulls
    a.delete("file.txt");
    a.engine().push(&a.link).unwrap();
    b.engine().pull(&b.link).unwrap();
    assert!(!b.exists("file.txt"));
    assert_eq!(b.read("new.txt"), "round3"); // still there
}

// ---------------------------------------------------------------------------
// Bidirectional sync
// ---------------------------------------------------------------------------

/// Both machines push AND pull across multiple rounds.
#[test]
fn bidirectional_sync_rounds() {
    let (_remote, a, b) = two_machines("bidir-sync");

    // Round 1: A pushes file1, B pulls and verifies
    a.write("file1.txt", "from-a-v1");
    bootstrap(&a, &b);
    assert_eq!(b.read("file1.txt"), "from-a-v1");

    // Round 2: B modifies file1, adds file2, pushes; A pulls and verifies
    b.write("file1.txt", "from-b-v1");
    b.write("file2.txt", "new-from-b");
    b.engine().push(&b.link).unwrap();
    a.engine().pull(&a.link).unwrap();
    assert_eq!(a.read("file1.txt"), "from-b-v1");
    assert_eq!(a.read("file2.txt"), "new-from-b");

    // Round 3: A modifies file2, pushes; B pulls and verifies
    a.write("file2.txt", "modified-by-a");
    a.engine().push(&a.link).unwrap();
    b.engine().pull(&b.link).unwrap();
    assert_eq!(b.read("file2.txt"), "modified-by-a");
    assert_eq!(b.read("file1.txt"), "from-b-v1"); // unchanged
}

// ---------------------------------------------------------------------------
// Concurrent push conflict
// ---------------------------------------------------------------------------

/// Two machines push without pulling — second push should fail with conflict.
#[test]
fn concurrent_push_detects_conflict() {
    let (_remote, a, b) = two_machines("concurrent-push");

    a.write("shared.txt", "initial");
    bootstrap(&a, &b);

    // A modifies and pushes
    a.write("shared.txt", "from-a");
    a.engine().push(&a.link).unwrap();

    // B modifies and pushes WITHOUT pulling first → should conflict
    b.write("shared.txt", "from-b");
    let result = b.engine().push(&b.link);

    assert!(
        result.is_err(),
        "B's push without pull should fail with conflict"
    );
    let err = result.unwrap_err().to_string();
    // The error comes from git non-fast-forward rejection
    assert!(
        err.to_lowercase().contains("conflict")
            || err.to_lowercase().contains("non-fast-forward")
            || err.to_lowercase().contains("rejected"),
        "error should indicate conflict: {}",
        err
    );
}

// ---------------------------------------------------------------------------
// has_remote_changes detection
// ---------------------------------------------------------------------------

/// has_remote_changes reflects whether new pushes exist on the remote.
#[test]
fn has_remote_changes_detects_new_push() {
    let (_remote, a, b) = two_machines("has-remote-changes");

    a.write("data.txt", "v1");
    bootstrap(&a, &b);

    // B just synced — no remote changes
    let transport_b = GitTransport::new(b.paths.clone());
    assert!(
        !transport_b.has_remote_changes(&b.link).unwrap(),
        "should be up-to-date right after bootstrap"
    );

    // A pushes new content
    a.write("data.txt", "v2");
    a.engine().push(&a.link).unwrap();

    // B should detect remote changes
    assert!(
        transport_b.has_remote_changes(&b.link).unwrap(),
        "should detect A's push as remote change"
    );

    // B pulls
    b.engine().pull(&b.link).unwrap();
    assert_eq!(b.read("data.txt"), "v2");

    // After pulling, no more remote changes
    assert!(
        !transport_b.has_remote_changes(&b.link).unwrap(),
        "should be up-to-date after pull"
    );
}

// ---------------------------------------------------------------------------
// list_remote_links discovery
// ---------------------------------------------------------------------------

/// list_remote_links discovers link names from a repo's syncor.toml.
#[test]
fn list_remote_links_discovers_links() {
    let (remote, a, _b) = two_machines("list-links");

    // A links and pushes (push creates syncor.toml via ensure_syncor_toml)
    a.write("file.txt", "content");
    a.engine().init_link(&a.link).unwrap();
    a.engine().push(&a.link).unwrap();

    // Use transport to list remote links from the bare repo URL
    let transport = GitTransport::new(a.paths.clone());
    let repo_url = remote.path().to_str().unwrap();
    let links = transport.list_remote_links(repo_url).unwrap();

    assert!(
        !links.is_empty(),
        "should discover at least one link in the remote"
    );
    assert!(
        links.iter().any(|l| l.name == "list-links"),
        "should find the 'list-links' link name, got: {:?}",
        links.iter().map(|l| &l.name).collect::<Vec<_>>()
    );
}

// ---------------------------------------------------------------------------
// Pull when already up to date
// ---------------------------------------------------------------------------

/// Pulling when already synced returns restored: false.
#[test]
fn pull_when_up_to_date_returns_no_change() {
    let (_remote, a, b) = two_machines("up-to-date");

    a.write("file.txt", "content");
    bootstrap(&a, &b);
    assert_eq!(b.read("file.txt"), "content");

    // B pulls again immediately — nothing has changed on remote
    let result = b.engine().pull(&b.link).unwrap();
    assert!(
        !result.restored,
        "second pull with no remote changes should return restored: false"
    );
}
