//! Multi-agent safety: concurrent writers, cwd-independence, and containment.
//!
//! These tests exercise the failure modes of two or more agents (or a parent
//! and its subagents) sharing one contract: racing `add` calls, racing
//! `prove` calls, commands invoked from subdirectories, and state discovery
//! leaking across repo boundaries.

use assert_cmd::Command;
use predicates::prelude::*;
use std::collections::HashSet;
use std::path::Path;
use tempfile::TempDir;

fn ritalin() -> Command {
    let mut cmd = Command::cargo_bin("ritalin").unwrap();
    cmd.env_remove("RITALIN_GATE");
    cmd
}

fn init_in(dir: &Path) {
    ritalin()
        .args(["init", "--outcome", "test outcome"])
        .current_dir(dir)
        .assert()
        .success();
}

fn git_init(dir: &Path) {
    for args in [
        vec!["init", "-q"],
        vec!["config", "user.email", "t@t"],
        vec!["config", "user.name", "t"],
    ] {
        std::process::Command::new("git")
            .args(&args)
            .current_dir(dir)
            .output()
            .unwrap();
    }
}

// ─── Concurrent add: unique ids, uncorrupted ledger ─────────

#[test]
fn concurrent_adds_mint_unique_ids() {
    let tmp = TempDir::new().unwrap();
    let dir = tmp.path().to_path_buf();
    init_in(&dir);

    const N: usize = 8;
    let handles: Vec<_> = (0..N)
        .map(|i| {
            let dir = dir.clone();
            std::thread::spawn(move || {
                ritalin()
                    .args(["add", &format!("claim {i}"), "--proof", "true"])
                    .current_dir(&dir)
                    .assert()
                    .success();
            })
        })
        .collect();
    for h in handles {
        h.join().unwrap();
    }

    // Every line parses (no interleaved writes) and every id is unique.
    let ledger = std::fs::read_to_string(dir.join(".ritalin/obligations.jsonl")).unwrap();
    let mut ids = HashSet::new();
    let mut count = 0;
    for line in ledger.lines().filter(|l| !l.trim().is_empty()) {
        let ob: serde_json::Value = serde_json::from_str(line)
            .unwrap_or_else(|e| panic!("corrupt ledger line ({e}): {line}"));
        assert!(
            ids.insert(ob["id"].as_str().unwrap().to_string()),
            "duplicate obligation id: {}",
            ob["id"]
        );
        count += 1;
    }
    assert_eq!(count, N, "all {N} adds should have landed");
}

// ─── Concurrent prove: evidence ledger stays parseable ──────

#[test]
fn concurrent_proves_do_not_corrupt_evidence_ledger() {
    let tmp = TempDir::new().unwrap();
    let dir = tmp.path().to_path_buf();
    init_in(&dir);
    ritalin()
        .args(["add", "always true", "--proof", "true"])
        .current_dir(&dir)
        .assert()
        .success();

    const N: usize = 8;
    let handles: Vec<_> = (0..N)
        .map(|_| {
            let dir = dir.clone();
            std::thread::spawn(move || {
                ritalin()
                    .args(["prove", "O-001"])
                    .current_dir(&dir)
                    .assert()
                    .success();
            })
        })
        .collect();
    for h in handles {
        h.join().unwrap();
    }

    let ledger = std::fs::read_to_string(dir.join(".ritalin/evidence.jsonl")).unwrap();
    let lines: Vec<_> = ledger.lines().filter(|l| !l.trim().is_empty()).collect();
    assert_eq!(lines.len(), N);
    for line in &lines {
        serde_json::from_str::<serde_json::Value>(line)
            .unwrap_or_else(|e| panic!("corrupt evidence line ({e}): {line}"));
    }
    // And the gate can still read everything.
    ritalin()
        .args(["gate"])
        .current_dir(&dir)
        .assert()
        .success();
}

// ─── Proofs run from the contract root, not the caller's cwd ─

#[test]
fn prove_runs_proof_from_contract_root() {
    let tmp = TempDir::new().unwrap();
    let dir = tmp.path();
    std::fs::write(dir.join("ok.txt"), "works").unwrap();
    init_in(dir);
    ritalin()
        .args([
            "add",
            "ok.txt says works",
            "--proof",
            "grep -q works ok.txt",
        ])
        .current_dir(dir)
        .assert()
        .success();

    // Invoke prove from a subdirectory. The relative path in the proof must
    // resolve against the contract root, not the subdirectory.
    let sub = dir.join("deep/nested");
    std::fs::create_dir_all(&sub).unwrap();
    ritalin()
        .args(["prove", "O-001"])
        .current_dir(&sub)
        .assert()
        .success();
    ritalin()
        .args(["gate"])
        .current_dir(&sub)
        .assert()
        .success();
}

// ─── init/seed containment: never wipe an ancestor's contract ─

#[test]
fn init_force_in_subdir_does_not_wipe_ancestor_contract() {
    let tmp = TempDir::new().unwrap();
    let root = tmp.path();
    init_in(root);
    ritalin()
        .args(["add", "root obligation", "--proof", "true"])
        .current_dir(root)
        .assert()
        .success();

    let sub = root.join("packages/web");
    std::fs::create_dir_all(&sub).unwrap();
    ritalin()
        .args(["init", "--outcome", "nested contract", "--force"])
        .current_dir(&sub)
        .assert()
        .success();

    // The ancestor's ledger must be untouched, and the nested contract
    // must live in the subdirectory.
    assert!(root.join(".ritalin/obligations.jsonl").exists());
    assert!(sub.join(".ritalin").exists());
    let ledger = std::fs::read_to_string(root.join(".ritalin/obligations.jsonl")).unwrap();
    assert!(ledger.contains("root obligation"));
}

#[test]
fn init_in_subdir_warns_about_shadowed_ancestor() {
    let tmp = TempDir::new().unwrap();
    let root = tmp.path();
    init_in(root);

    let sub = root.join("sub");
    std::fs::create_dir_all(&sub).unwrap();
    let out = ritalin()
        .args(["init", "--outcome", "nested", "--json"])
        .current_dir(&sub)
        .output()
        .unwrap();
    let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    assert!(
        json["data"]["shadows"]
            .as_str()
            .unwrap()
            .contains(".ritalin"),
        "nested init should report the ancestor contract it shadows"
    );
}

// ─── State discovery stops at the git-repo boundary ─────────

#[test]
fn state_discovery_does_not_cross_git_boundary() {
    let tmp = TempDir::new().unwrap();
    let outer = tmp.path();
    init_in(outer);
    ritalin()
        .args(["add", "outer obligation", "--proof", "false"])
        .current_dir(outer)
        .assert()
        .success();

    // A separate git repo nested under the contract-bearing directory must
    // NOT inherit the outer contract: its Stop hook stops cleanly.
    let inner = outer.join("some-other-project");
    std::fs::create_dir_all(&inner).unwrap();
    git_init(&inner);

    ritalin()
        .args(["gate", "--hook-mode"])
        .write_stdin("{}")
        .current_dir(&inner)
        .assert()
        .success()
        .stdout(predicate::str::is_empty());

    ritalin()
        .args(["status"])
        .current_dir(&inner)
        .assert()
        .failure();
}

#[test]
fn state_discovery_walks_up_within_one_repo() {
    let tmp = TempDir::new().unwrap();
    let root = tmp.path();
    git_init(root);
    init_in(root);
    ritalin()
        .args(["add", "repo obligation", "--proof", "false"])
        .current_dir(root)
        .assert()
        .success();

    // A plain subdirectory of the same repo still finds the contract.
    let sub = root.join("src/deep");
    std::fs::create_dir_all(&sub).unwrap();
    ritalin()
        .args(["gate", "--hook-mode"])
        .write_stdin("{}")
        .current_dir(&sub)
        .assert()
        .success()
        .stdout(predicate::str::contains("\"block\""));
}

// ─── seed validates like add ────────────────────────────────

#[test]
fn seed_rejects_escaping_depends_on() {
    let tmp = TempDir::new().unwrap();
    let dir = tmp.path();
    let manifest = dir.join("contract.toml");
    std::fs::write(
        &manifest,
        r#"outcome = "escape attempt"
[[obligations]]
claim = "reads outside repo"
proof = "true"
depends_on = ["../etc/passwd"]
"#,
    )
    .unwrap();

    ritalin()
        .args(["seed", manifest.to_str().unwrap()])
        .current_dir(dir)
        .assert()
        .failure()
        .stderr(predicate::str::contains("must not contain `..`"));
    // Validation happens before any state is written.
    assert!(!dir.join(".ritalin").exists());
}

#[test]
fn seed_rejects_empty_proof() {
    let tmp = TempDir::new().unwrap();
    let dir = tmp.path();
    let manifest = dir.join("contract.toml");
    std::fs::write(
        &manifest,
        r#"outcome = "empty proof"
[[obligations]]
claim = "unprovable"
proof = "   "
"#,
    )
    .unwrap();

    ritalin()
        .args(["seed", manifest.to_str().unwrap()])
        .current_dir(dir)
        .assert()
        .failure()
        .stderr(predicate::str::contains("empty proof command"));
}

// ─── prove --all resilience ─────────────────────────────────

#[test]
fn prove_all_continues_past_broken_depends_on() {
    let tmp = TempDir::new().unwrap();
    let dir = tmp.path();
    init_in(dir);

    ritalin()
        .args([
            "add",
            "dep never created",
            "--proof",
            "true",
            "--depends-on",
            "never-created.txt",
        ])
        .current_dir(dir)
        .assert()
        .success();
    ritalin()
        .args(["add", "healthy obligation", "--proof", "true"])
        .current_dir(dir)
        .assert()
        .success();

    // The broken scope must fail only O-001; O-002 still gets proved.
    let out = ritalin()
        .args(["prove", "--all", "--json"])
        .current_dir(dir)
        .output()
        .unwrap();
    assert!(!out.status.success());
    let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    let summary = &json["data"]["summary"];
    assert_eq!(summary["total"], 2);
    assert_eq!(summary["failed"], 1);
    assert_eq!(summary["discharged"], 1);
}

#[test]
fn stale_only_reproves_obligations_invalidated_within_the_run() {
    let tmp = TempDir::new().unwrap();
    let dir = tmp.path();
    git_init(dir);
    std::fs::write(dir.join("seed.txt"), "v1").unwrap();
    std::process::Command::new("git")
        .args(["add", "."])
        .current_dir(dir)
        .output()
        .unwrap();
    std::process::Command::new("git")
        .args(["commit", "-q", "-m", "init"])
        .current_dir(dir)
        .output()
        .unwrap();
    init_in(dir);

    // O-001 mutates seed.txt when proved; O-002 depends on seed.txt.
    ritalin()
        .args([
            "add",
            "mutating proof",
            "--proof",
            "echo mutated > seed.txt",
            "--depends-on",
            "seed.txt",
        ])
        .current_dir(dir)
        .assert()
        .success();
    ritalin()
        .args([
            "add",
            "seed exists",
            "--proof",
            "test -f seed.txt",
            "--depends-on",
            "seed.txt",
        ])
        .current_dir(dir)
        .assert()
        .success();

    // Make O-002's evidence fresh before the batch run.
    ritalin()
        .args(["prove", "O-002"])
        .current_dir(dir)
        .assert()
        .success();

    // O-001 runs first and rewrites seed.txt. O-002's pre-run evidence is
    // now stale, so --stale-only must re-prove it, not skip it.
    let out = ritalin()
        .args(["prove", "--all", "--stale-only", "--json"])
        .current_dir(dir)
        .output()
        .unwrap();
    assert!(out.status.success());
    let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
    let summary = &json["data"]["summary"];
    assert_eq!(
        summary["skipped"], 0,
        "O-002 was invalidated mid-run and must not be skipped as fresh"
    );
    let proved = json["data"]["proved"].as_array().unwrap();
    assert_eq!(proved.len(), 2);
}

// ─── Submodules stay inside the family ──────────────────────

#[test]
fn submodule_boundary_does_not_orphan_outer_contract() {
    // A `.git` FILE (submodule / linked worktree) is subordinate to a parent
    // checkout: walking up from inside it must still find the outer
    // contract. Only a `.git` DIRECTORY (independent repo) stops the walk.
    let tmp = TempDir::new().unwrap();
    let outer = tmp.path();
    git_init(outer);
    init_in(outer);
    ritalin()
        .args(["add", "outer obligation", "--proof", "false"])
        .current_dir(outer)
        .assert()
        .success();

    let sub = outer.join("vendor/sub/src");
    std::fs::create_dir_all(&sub).unwrap();
    // The gitdir target must exist — discovery resolves it to find the
    // owning repo root, exactly like a real `git submodule add` layout.
    std::fs::create_dir_all(outer.join(".git/modules/sub")).unwrap();
    std::fs::write(
        outer.join("vendor/sub/.git"),
        "gitdir: ../../.git/modules/sub\n",
    )
    .unwrap();

    // The outer contract is discovered through the submodule boundary…
    ritalin()
        .args(["status"])
        .current_dir(&sub)
        .assert()
        .success();
    // …and its gate still blocks.
    ritalin()
        .args(["gate", "--hook-mode"])
        .write_stdin("{}")
        .current_dir(&sub)
        .assert()
        .success()
        .stdout(predicate::str::contains("\"block\""));
}

// ─── stale-only cache: out-of-scope mutation invalidates ────

#[test]
fn stale_only_detects_out_of_scope_mutation() {
    // A scoped proof (depends_on = a.txt) that mutates a file OUTSIDE its
    // scope (shared.txt) doesn't set its own workspace_mutated flag — but
    // it does change the global workspace hash. Global-scope obligations
    // checked afterwards must not be skipped off a stale cache, and the
    // final remaining_open sweep must surface anything invalidated before
    // it could be re-checked.
    let tmp = TempDir::new().unwrap();
    let dir = tmp.path();
    git_init(dir);
    std::fs::write(dir.join("a.txt"), "a").unwrap();
    std::fs::write(dir.join("shared.txt"), "v1").unwrap();
    std::process::Command::new("git")
        .args(["add", "."])
        .current_dir(dir)
        .output()
        .unwrap();
    std::process::Command::new("git")
        .args(["commit", "-q", "-m", "init"])
        .current_dir(dir)
        .output()
        .unwrap();
    init_in(dir);

    // O-001: global scope. O-002: scoped to a.txt, mutates shared.txt.
    // O-003: global scope.
    ritalin()
        .args(["add", "global one", "--proof", "test -f shared.txt"])
        .current_dir(dir)
        .assert()
        .success();
    ritalin()
        .args([
            "add",
            "scoped mutator",
            "--proof",
            "echo mutated >> shared.txt",
            "--depends-on",
            "a.txt",
        ])
        .current_dir(dir)
        .assert()
        .success();
    ritalin()
        .args(["add", "global two", "--proof", "true"])
        .current_dir(dir)
        .assert()
        .success();

    // Make O-001 and O-003 fresh before the batch run.
    ritalin()
        .args(["prove", "O-001"])
        .current_dir(dir)
        .assert()
        .success();
    ritalin()
        .args(["prove", "O-003"])
        .current_dir(dir)
        .assert()
        .success();

    let out = ritalin()
        .args(["prove", "--all", "--stale-only", "--json"])
        .current_dir(dir)
        .output()
        .unwrap();
    assert!(out.status.success());
    let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();

    // O-001 was legitimately skipped (checked before the mutation), but
    // O-003 must be re-proved — the cache was invalidated by O-002's run.
    let skipped: Vec<_> = json["data"]["skipped"]
        .as_array()
        .unwrap()
        .iter()
        .map(|s| s["obligation_id"].as_str().unwrap().to_string())
        .collect();
    assert_eq!(skipped, vec!["O-001"], "O-003 must not be skipped as fresh");

    // And the final sweep tells the truth: O-001's evidence went stale
    // mid-run, so it is reported open even though every command passed.
    let remaining: Vec<_> = json["data"]["remaining_open"]["ids"]
        .as_array()
        .unwrap()
        .iter()
        .map(|v| v.as_str().unwrap().to_string())
        .collect();
    assert!(
        remaining.contains(&"O-001".to_string()),
        "remaining_open must surface the mid-run invalidation, got {remaining:?}"
    );
}

// ─── Linked worktrees of foreign repos are not captured ─────

#[test]
fn foreign_worktree_under_contract_dir_is_not_captured() {
    // A linked worktree can be parked anywhere. One that belongs to an
    // UNRELATED repo but happens to live under a directory holding a stray
    // .ritalin must not adopt that contract — a `.git` file only continues
    // the walk when its owning repo root is an ancestor.
    let tmp = TempDir::new().unwrap();

    // The unrelated main checkout, somewhere else entirely.
    let victim = tmp.path().join("elsewhere/victim-main");
    std::fs::create_dir_all(&victim).unwrap();
    git_init(&victim);
    std::fs::create_dir_all(victim.join(".git/worktrees/wt")).unwrap();

    // A shared folder with a stray contract in it.
    let projects = tmp.path().join("Projects");
    std::fs::create_dir_all(&projects).unwrap();
    init_in(&projects);
    ritalin()
        .args(["add", "stray obligation", "--proof", "false"])
        .current_dir(&projects)
        .assert()
        .success();

    // The victim's worktree, parked under Projects/ for convenience.
    let wt = projects.join("victim-worktree");
    std::fs::create_dir_all(&wt).unwrap();
    std::fs::write(
        wt.join(".git"),
        format!("gitdir: {}\n", victim.join(".git/worktrees/wt").display()),
    )
    .unwrap();

    // The stray contract must be invisible from inside the worktree…
    ritalin()
        .args(["status"])
        .current_dir(&wt)
        .assert()
        .failure();
    // …and its Stop hook must not be hijacked.
    ritalin()
        .args(["gate", "--hook-mode"])
        .write_stdin("{}")
        .current_dir(&wt)
        .assert()
        .success()
        .stdout(predicate::str::is_empty());
}

#[test]
fn own_worktree_under_contract_repo_still_finds_contract() {
    // The flip side: a worktree kept INSIDE its own main checkout (a common
    // layout, e.g. repo/.worktrees/x) stays in the family — the owner root
    // is an ancestor, so the contract is still discovered.
    let tmp = TempDir::new().unwrap();
    let repo = tmp.path().join("repo");
    std::fs::create_dir_all(&repo).unwrap();
    git_init(&repo);
    init_in(&repo);
    ritalin()
        .args(["add", "repo obligation", "--proof", "false"])
        .current_dir(&repo)
        .assert()
        .success();

    std::fs::create_dir_all(repo.join(".git/worktrees/x")).unwrap();
    let wt = repo.join(".worktrees/x");
    std::fs::create_dir_all(&wt).unwrap();
    std::fs::write(wt.join(".git"), "gitdir: ../../.git/worktrees/x\n").unwrap();

    ritalin()
        .args(["gate", "--hook-mode"])
        .write_stdin("{}")
        .current_dir(&wt)
        .assert()
        .success()
        .stdout(predicate::str::contains("\"block\""));
}
