//! Item #26: regression net for the wave-9 `with_repo_lock` flock.
//!
//! Spawns two `ckg` child processes against the same repo: one running
//! `ckg index --clean` and another running `ckg remove`. The flock
//! must serialise them so the manifest ends in a consistent state
//! (either the index's entry is present and `db_dir` exists, OR the
//! entry is absent and `db_dir` is gone — never the contradictory
//! "manifest entry exists but db_dir is gone" state).
//!
//! `assert_cmd` would simplify the harness but adds a dev-dep; instead
//! use `std::process::Command` with `env!("CARGO_BIN_EXE_ckg")` which
//! Cargo provides automatically for integration tests.
//!
//! `#[ignore]`-d by default because spawning subprocesses is slow;
//! run with `cargo test --test concurrent_index_remove -- --ignored`.

use std::fs;
use std::process::{Command, Stdio};
use std::thread;
use std::time::Duration;

/// Build a minimal git repo fixture under `dir`.
fn make_fixture_repo(dir: &std::path::Path) {
    fs::create_dir_all(dir.join(".git")).unwrap();
    // Need an actual ref so ckg-git can resolve HEAD.
    fs::create_dir_all(dir.join(".git/refs/heads")).unwrap();
    fs::write(dir.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
    // A few source files.
    fs::write(
        dir.join("a.py"),
        "def hello(): pass\nclass Foo:\n    def bar(self): pass\n",
    )
    .unwrap();
    fs::write(
        dir.join("b.py"),
        "from a import hello\n\ndef caller():\n    hello()\n",
    )
    .unwrap();
}

#[test]
#[ignore = "slow; spawns subprocesses. Run via `cargo test --test concurrent_index_remove -- --ignored`"]
fn index_and_remove_dont_corrupt_manifest() {
    let tmp = tempfile::tempdir().unwrap();
    let ckg_home = tmp.path().join("ckg-home");
    let repo = tmp.path().join("repo");
    make_fixture_repo(&repo);

    let bin = env!("CARGO_BIN_EXE_ckg");

    // Phase 1: index once so there's a baseline.
    let status = Command::new(bin)
        .args([
            "--home",
            ckg_home.to_str().unwrap(),
            "index",
            "--no-embed",
            "--no-scip",
            repo.to_str().unwrap(),
        ])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .expect("spawn ckg index baseline");
    assert!(status.success(), "baseline index failed");

    // Phase 2: spawn `index --clean` and `remove` concurrently. Whichever
    // wins the per-repo flock first does its work; the other waits.
    let bin_idx = bin.to_string();
    let bin_rm = bin.to_string();
    let home_idx = ckg_home.clone();
    let home_rm = ckg_home.clone();
    let repo_idx = repo.clone();
    let repo_rm = repo.clone();

    let idx = thread::spawn(move || {
        Command::new(&bin_idx)
            .args([
                "--home",
                home_idx.to_str().unwrap(),
                "index",
                "--clean",
                "--no-embed",
                "--no-scip",
                repo_idx.to_str().unwrap(),
            ])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .expect("spawn ckg index --clean")
    });

    // Tiny stagger so both subprocesses are in flight; the flock
    // serialises them regardless of order.
    thread::sleep(Duration::from_millis(50));

    let rm = thread::spawn(move || {
        Command::new(&bin_rm)
            .args([
                "--home",
                home_rm.to_str().unwrap(),
                "remove",
                repo_rm.to_str().unwrap(),
            ])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .expect("spawn ckg remove")
    });

    let idx_status = idx.join().expect("index thread panicked");
    let rm_status = rm.join().expect("remove thread panicked");
    // Both should exit 0 — `ckg remove` returns success even if entry
    // is absent.
    assert!(
        idx_status.success() || idx_status.code() == Some(0),
        "index exit: {idx_status}"
    );
    assert!(rm_status.success(), "remove exit: {rm_status}");

    // Phase 3: invariant check. Read manifest; if it lists the repo,
    // its `db_dir` MUST exist with a `CURRENT` file. If it doesn't
    // list it, `db_dir` MUST be absent or empty. The "entry exists
    // but db gone" inconsistency is what the flock + CURRENT-gate
    // close.
    let manifest_path = ckg_home.join("manifest.json");
    let manifest_str = fs::read_to_string(&manifest_path).unwrap_or_default();
    // Resolve repo_id deterministically: ckg uses
    // hex(blake3(canonical_path)[..12]).
    let canonical = repo.canonicalize().unwrap_or(repo.clone());
    let mut hasher = blake3::Hasher::new();
    #[cfg(unix)]
    {
        use std::os::unix::ffi::OsStrExt;
        hasher.update(canonical.as_os_str().as_bytes());
    }
    #[cfg(not(unix))]
    {
        hasher.update(canonical.to_string_lossy().as_bytes());
    }
    let repo_id = hex::encode(&hasher.finalize().as_bytes()[..12]);
    let db_dir = ckg_home.join("workspace_folders").join(&repo_id);
    let manifest_lists_repo = manifest_str.contains(&repo_id);
    let db_dir_healthy = db_dir.join("CURRENT").is_file();
    if manifest_lists_repo {
        assert!(
            db_dir_healthy,
            "manifest lists {repo_id} but db_dir lacks CURRENT — wave-9 flock failed to prevent inconsistency"
        );
    } else {
        // Either remove won the race or the index hasn't been re-applied yet.
        // Either way: db_dir should not show up as "indexed" without the manifest entry.
        // Acceptable states: (db_dir absent) OR (db_dir exists but no CURRENT).
        // The contradictory state would be: manifest absent, db_dir healthy,
        // claiming an indexed repo we don't track. That's not what we test;
        // the flock+gate aim at the manifest-side invariant.
    }
}
