use assert_cmd::cargo::CommandCargoExt as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tempfile::TempDir;

/// A throwaway org vault with a device signing key wired via XDG_CONFIG_HOME.
struct OrgFixture {
    _config: TempDir,
    vault: TempDir,
    xdg: PathBuf,
}

impl OrgFixture {
    /// Generate an ed25519 signing key in OpenSSH format using ssh-keygen and
    /// register it as the current device, then `org init`.
    fn new() -> Self {
        let config = TempDir::new().unwrap();
        let xdg = config.path().to_path_buf();
        let devices = xdg.join("relicario").join("devices").join("laptop");
        std::fs::create_dir_all(&devices).unwrap();

        // Generate an OpenSSH ed25519 keypair without a passphrase.
        let keyfile = devices.join("signing.key");
        let status = Command::new("ssh-keygen")
            .args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"])
            .arg(&keyfile)
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .expect("ssh-keygen");
        assert!(status.success(), "ssh-keygen failed");
        // ssh-keygen writes signing.key + signing.key.pub; rename the .pub to signing.pub.
        std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
        // Mark this device current.
        std::fs::write(
            xdg.join("relicario").join("devices").join("current"),
            "laptop\n",
        )
        .unwrap();

        let vault = TempDir::new().unwrap();
        let f = OrgFixture { _config: config, vault, xdg };

        let out = f.run(&["org", "init", "--dir", f.vault_str(), "--name", "Acme"]);
        assert!(out.status.success(), "org init failed: {}", String::from_utf8_lossy(&out.stderr));
        f
    }

    fn vault_path(&self) -> &Path { self.vault.path() }
    fn vault_str(&self) -> &str { self.vault.path().to_str().unwrap() }

    fn run(&self, args: &[&str]) -> std::process::Output {
        let mut cmd = Command::cargo_bin("relicario").unwrap();
        cmd.env("XDG_CONFIG_HOME", &self.xdg)
            .env("RELICARIO_ORG_DIR", self.vault.path())
            .args(args)
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());
        cmd.output().unwrap()
    }

    /// Owner member id printed by `org init`/`org status`. We read it from
    /// members.json directly to avoid parsing stdout.
    fn owner_member_id(&self) -> String {
        let s = std::fs::read_to_string(self.vault.path().join("members.json")).unwrap();
        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
        v["members"][0]["member_id"].as_str().unwrap().to_string()
    }

    /// Like `run`, but pipes `stdin_data` into the child's stdin — used to drive
    /// `--*-stdin` secret flags and the interactive edit prompts. `wait_with_output`
    /// closes stdin for us, so multiline secrets (read-to-EOF) terminate cleanly.
    fn run_stdin(&self, args: &[&str], stdin_data: &str) -> std::process::Output {
        use std::io::Write as _;
        let mut child = Command::cargo_bin("relicario")
            .unwrap()
            .env("XDG_CONFIG_HOME", &self.xdg)
            .env("RELICARIO_ORG_DIR", self.vault.path())
            .args(args)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .unwrap();
        child.stdin.as_mut().unwrap().write_all(stdin_data.as_bytes()).unwrap();
        child.wait_with_output().unwrap()
    }

    /// Create collection `slug` and grant the owner access to it — the common
    /// setup the item-type round-trip tests share.
    fn create_collection_and_grant(&self, slug: &str) {
        let owner = self.owner_member_id();
        assert!(
            self.run(&["org", "create-collection", slug, "--name", slug]).status.success(),
            "create-collection {slug} failed",
        );
        assert!(
            self.run(&["org", "grant", &owner, slug]).status.success(),
            "grant {slug} failed",
        );
    }
}

#[test]
fn org_add_get_list_round_trip() {
    let f = OrgFixture::new();
    let owner = f.owner_member_id();

    // Create a collection and grant the owner access to it.
    let out = f.run(&["org", "create-collection", "prod", "--name", "Production"]);
    assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr));
    let out = f.run(&["org", "grant", &owner, "prod"]);
    assert!(out.status.success(), "grant: {}", String::from_utf8_lossy(&out.stderr));

    // Add a login into the prod collection.
    let out = f.run(&[
        "org", "add", "login", "--collection", "prod",
        "--title", "GitHub", "--username", "alice",
        "--url", "https://github.com", "--password", "hunter2",
    ]);
    assert!(out.status.success(), "org add: {}", String::from_utf8_lossy(&out.stderr));

    // The blob must live under items/prod/, NOT flat items/.
    let prod_dir = f.vault_path().join("items").join("prod");
    let blobs: Vec<_> = std::fs::read_dir(&prod_dir).unwrap().collect();
    assert_eq!(blobs.len(), 1, "expected one blob under items/prod/");

    // list shows it.
    let out = f.run(&["org", "list"]);
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    assert!(stdout.contains("GitHub"), "list missing GitHub: {stdout}");

    // get masks by default.
    let out = f.run(&["org", "get", "GitHub"]);
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    assert!(stdout.contains("********"), "expected masked secret: {stdout}");
    assert!(!stdout.contains("hunter2"), "leaked plaintext: {stdout}");

    // get --show reveals.
    let out = f.run(&["org", "get", "GitHub", "--show"]);
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    assert!(stdout.contains("hunter2"), "expected plaintext with --show: {stdout}");

    // The commit trailer records the action + collection + item.
    let log = Command::new("git")
        .args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
        .output()
        .unwrap();
    let body = String::from_utf8_lossy(&log.stdout).to_string();
    assert!(body.contains("Relicario-Action: item-create"), "missing action trailer: {body}");
    assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
    assert!(body.contains("Relicario-Item: "), "missing item trailer: {body}");
}

#[test]
fn org_add_rejects_ungranted_collection() {
    let f = OrgFixture::new();
    // Create the collection but do NOT grant the owner.
    let out = f.run(&["org", "create-collection", "secret", "--name", "Secret"]);
    assert!(out.status.success(), "create-collection: {}", String::from_utf8_lossy(&out.stderr));

    let out = f.run(&[
        "org", "add", "login", "--collection", "secret",
        "--title", "X", "--username", "u", "--password", "p",
    ]);
    assert!(!out.status.success(), "add into ungranted collection must fail");
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}");
}

#[test]
fn org_add_rejects_unknown_collection() {
    let f = OrgFixture::new();
    let out = f.run(&[
        "org", "add", "login", "--collection", "ghost",
        "--title", "X", "--username", "u", "--password", "p",
    ]);
    assert!(!out.status.success(), "add into nonexistent collection must fail");
    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
    assert!(stderr.contains("does not exist") || stderr.contains("ghost"), "unexpected error: {stderr}");
}

#[test]
fn org_edit_updates_fields_and_commits_update_trailer() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("prod");
    assert!(f.run(&[
        "org", "add", "login", "--collection", "prod",
        "--title", "Mail", "--username", "old", "--password", "pw",
    ]).status.success());

    // org edit is now interactive per-type: keep title, set username=new-user,
    // keep URL, decline password change.
    let out = f.run_stdin(&["org", "edit", "Mail"], "\nnew-user\n\nn\n");
    assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr));

    let out = f.run(&["org", "get", "Mail", "--show"]);
    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
    assert!(stdout.contains("new-user"), "edit did not take: {stdout}");

    let log = Command::new("git")
        .args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
        .output().unwrap();
    let body = String::from_utf8_lossy(&log.stdout).to_string();
    assert!(body.contains("Relicario-Action: item-update"), "missing update trailer: {body}");
    assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
}

#[test]
fn org_rm_restore_purge_cycle() {
    let f = OrgFixture::new();
    let owner = f.owner_member_id();
    assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
    assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
    assert!(f.run(&[
        "org", "add", "secure-note", "--collection", "prod",
        "--title", "Recovery", "--body", "codes-here",
    ]).status.success());

    // rm → appears only with --trashed.
    assert!(f.run(&["org", "rm", "Recovery"]).status.success());
    let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
    assert!(!listed.contains("Recovery"), "trashed item still in default list: {listed}");
    let trashed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
    assert!(trashed.contains("Recovery"), "trashed item not in --trashed list: {trashed}");

    // restore → back in default list.
    assert!(f.run(&["org", "restore", "Recovery"]).status.success());
    let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
    assert!(listed.contains("Recovery"), "restore did not bring it back: {listed}");

    // purge → blob gone, entry gone, item-purge trailer.
    assert!(f.run(&["org", "purge", "Recovery"]).status.success());
    let prod_dir = f.vault_path().join("items").join("prod");
    let count = std::fs::read_dir(&prod_dir).map(|d| d.count()).unwrap_or(0);
    assert_eq!(count, 0, "blob not purged from items/prod/");
    let listed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
    assert!(!listed.contains("Recovery"), "purged item still listed: {listed}");

    let log = Command::new("git")
        .args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
        .output().unwrap();
    let body = String::from_utf8_lossy(&log.stdout).to_string();
    assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
}

// --- v0.8.1 org item-type parity: Card / Key / Totp -------------------------
// These drive the new `org add <card|key|totp>` subcommands. Secrets enter via
// `--*-stdin` (read from piped stdin) or, for Totp, the `--secret` flag. `org get`
// must mask every secret unless `--show` is passed — asserted below.

#[test]
fn org_add_card_via_stdin_then_get_masks_secret() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("eng");

    // build_card reads number, then cvv, then pin — one line each, in that order.
    let out = f.run_stdin(
        &[
            "org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
            "--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
        ],
        "4111111111111111\n123\n4321\n",
    );
    assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));

    // get masks the card number by default.
    let got = f.run(&["org", "get", "Corp Visa"]);
    let stdout = String::from_utf8_lossy(&got.stdout).to_string();
    assert!(stdout.contains("Corp Visa"), "title missing: {stdout}");
    assert!(stdout.contains("********"), "card number must be masked without --show: {stdout}");
    assert!(!stdout.contains("4111111111111111"), "secret leaked without --show: {stdout}");

    // --show reveals it.
    let shown = f.run(&["org", "get", "Corp Visa", "--show"]);
    let shown = String::from_utf8_lossy(&shown.stdout).to_string();
    assert!(shown.contains("4111111111111111"), "number not revealed with --show: {shown}");
}

#[test]
fn org_add_key_via_stdin_then_get_masks_material() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("eng");

    // build_key reads key material from stdin to EOF (multiline secret).
    let out = f.run_stdin(
        &[
            "org", "add", "key", "--collection", "eng", "--title", "Deploy Key",
            "--label", "ci", "--algorithm", "ed25519", "--material-stdin",
        ],
        "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAsecretmaterial\n-----END OPENSSH PRIVATE KEY-----\n",
    );
    assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));

    let got = f.run(&["org", "get", "Deploy Key"]);
    let stdout = String::from_utf8_lossy(&got.stdout).to_string();
    assert!(stdout.contains("Label:    ci"), "label missing: {stdout}");
    assert!(stdout.contains("********"), "key material must be masked without --show: {stdout}");
    assert!(!stdout.contains("secretmaterial"), "key material leaked without --show: {stdout}");
}

#[test]
fn org_add_totp_with_secret_flag_round_trips() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("eng");

    // Totp accepts the base32 secret via --secret (no stdin needed).
    let out = f.run(&[
        "org", "add", "totp", "--collection", "eng", "--title", "AWS root",
        "--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
    ]);
    assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));

    let got = f.run(&["org", "get", "AWS root"]);
    let stdout = String::from_utf8_lossy(&got.stdout).to_string();
    assert!(stdout.contains("AWS root"), "title missing: {stdout}");
    assert!(stdout.contains("Issuer:   AWS"), "issuer missing: {stdout}");
}

#[test]
fn org_edit_card_interactive_changes_holder() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("eng");
    let out = f.run_stdin(
        &[
            "org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
            "--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
        ],
        "4111111111111111\n123\n4321\n",
    );
    assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));

    // Interactive edit: keep title, set holder, decline number change.
    let out = f.run_stdin(&["org", "edit", "Corp Visa"], "\nJane Q. Public\nn\n");
    assert!(out.status.success(), "org edit card: {}", String::from_utf8_lossy(&out.stderr));

    let got = f.run(&["org", "get", "Corp Visa"]);
    let stdout = String::from_utf8_lossy(&got.stdout).to_string();
    assert!(stdout.contains("Holder:   Jane Q. Public"), "holder edit did not take: {stdout}");
    assert!(stdout.contains("********"), "number must stay masked after declining change: {stdout}");
    assert!(!stdout.contains("4111111111111111"), "number leaked without --show: {stdout}");
}

#[test]
fn org_edit_totp_interactive_changes_issuer() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("eng");
    assert!(f.run(&[
        "org", "add", "totp", "--collection", "eng", "--title", "AWS root",
        "--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
    ]).status.success());

    // Interactive edit: keep title, set issuer=GitHub, keep label, decline secret change.
    let out = f.run_stdin(&["org", "edit", "AWS root"], "\nGitHub\n\nn\n");
    assert!(out.status.success(), "org edit totp: {}", String::from_utf8_lossy(&out.stderr));

    let got = f.run(&["org", "get", "AWS root"]);
    assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer:   GitHub"), "issuer edit did not take");
}

// --- grant enforcement + remaining --*-stdin paths for the new types ---------

#[test]
fn org_add_card_key_totp_reject_ungranted_and_unknown_collection() {
    let f = OrgFixture::new();
    // `secret` exists but is NOT granted to the owner.
    assert!(f.run(&["org", "create-collection", "secret", "--name", "secret"]).status.success());

    // ensure_grant runs before any secret prompt in run_add, so these need no
    // stdin — each new type must be rejected for a collection it lacks a grant for.
    for args in [
        vec!["org", "add", "card", "--collection", "secret", "--title", "X", "--kind", "credit"],
        vec!["org", "add", "key", "--collection", "secret", "--title", "X"],
        vec!["org", "add", "totp", "--collection", "secret", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
    ] {
        let out = f.run(&args);
        assert!(!out.status.success(), "ungranted add must fail: {args:?}");
        let err = String::from_utf8_lossy(&out.stderr).to_string();
        assert!(err.contains("access denied") || err.contains("grant"),
                "expected grant denial for {args:?}: {err}");
    }

    // …and rejected for a nonexistent collection.
    for args in [
        vec!["org", "add", "card", "--collection", "ghost", "--title", "X", "--kind", "credit"],
        vec!["org", "add", "key", "--collection", "ghost", "--title", "X"],
        vec!["org", "add", "totp", "--collection", "ghost", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
    ] {
        let out = f.run(&args);
        assert!(!out.status.success(), "unknown-collection add must fail: {args:?}");
        let err = String::from_utf8_lossy(&out.stderr).to_string();
        assert!(err.contains("does not exist") || err.contains("ghost"),
                "expected unknown-collection error for {args:?}: {err}");
    }
}

#[test]
fn org_add_secure_note_via_body_stdin_masks_body() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("eng");
    // build_secure_note(body_stdin=true) reads the body from stdin to EOF.
    let out = f.run_stdin(
        &["org", "add", "secure-note", "--collection", "eng", "--title", "Runbook", "--body-stdin"],
        "line one\nsuper-secret-line\n",
    );
    assert!(out.status.success(), "add note: {}", String::from_utf8_lossy(&out.stderr));

    let got = f.run(&["org", "get", "Runbook"]);
    let stdout = String::from_utf8_lossy(&got.stdout).to_string();
    assert!(stdout.contains("********"), "note body must be masked without --show: {stdout}");
    assert!(!stdout.contains("super-secret-line"), "note body leaked without --show: {stdout}");

    let shown = f.run(&["org", "get", "Runbook", "--show"]);
    assert!(String::from_utf8_lossy(&shown.stdout).contains("super-secret-line"), "body not revealed with --show");
}

#[test]
fn org_add_totp_via_secret_stdin_round_trips() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("eng");
    // build_totp(secret_stdin=true) reads one base32 line from stdin.
    let out = f.run_stdin(
        &["org", "add", "totp", "--collection", "eng", "--title", "VPN", "--issuer", "Corp", "--secret-stdin"],
        "JBSWY3DPEHPK3PXP\n",
    );
    assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));

    let got = f.run(&["org", "get", "VPN"]);
    assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer:   Corp"), "issuer missing");
}

#[test]
fn org_edit_key_replaces_material_and_reveals_with_show() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("eng");
    let out = f.run_stdin(
        &["org", "add", "key", "--collection", "eng", "--title", "Signing Key",
          "--label", "ci", "--material-stdin"],
        "OLD-MATERIAL-aaaa\n",
    );
    assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));

    // Interactive edit: keep title, ACCEPT "Replace key material?" -> new material
    // read from stdin to EOF (edit_key). Exercises the accept branch + history push.
    let out = f.run_stdin(&["org", "edit", "Signing Key"], "\ny\nNEW-MATERIAL-bbbb\n");
    assert!(out.status.success(), "org edit key: {}", String::from_utf8_lossy(&out.stderr));

    let masked = f.run(&["org", "get", "Signing Key"]);
    let masked = String::from_utf8_lossy(&masked.stdout).to_string();
    assert!(masked.contains("********"), "material must be masked without --show: {masked}");
    assert!(!masked.contains("NEW-MATERIAL"), "material leaked without --show: {masked}");

    let shown = f.run(&["org", "get", "Signing Key", "--show"]);
    let shown = String::from_utf8_lossy(&shown.stdout).to_string();
    assert!(shown.contains("NEW-MATERIAL-bbbb"), "replaced material not revealed with --show: {shown}");
    assert!(!shown.contains("OLD-MATERIAL"), "old material still present after replace: {shown}");
}

// --- v0.8.1 org Document tests -----------------------------------------------

/// `git status --porcelain` output for the org repo (trimmed). Empty-of-`attachments/`
/// proves every attachment add/remove was staged into the signed commit.
fn git_porcelain(repo: &str) -> String {
    let out = std::process::Command::new("git")
        .args(["-C", repo, "status", "--porcelain"])
        .output()
        .unwrap();
    String::from_utf8_lossy(&out.stdout).trim().to_string()
}

#[test]
fn org_add_document_stores_collection_scoped_attachment() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("eng");
    let srcdir = TempDir::new().unwrap();
    let src = srcdir.path().join("note.txt");
    std::fs::write(&src, b"secret memo").unwrap();

    let out = f.run(&["org", "add", "document", "--collection", "eng",
                      "--title", "Q3 Memo", "--file", src.to_str().unwrap()]);
    assert!(out.status.success(), "add doc: {}", String::from_utf8_lossy(&out.stderr));

    // Encrypted blob at attachments/eng/<item-id>/<att-id>.enc (3 segments).
    let att_eng = f.vault_path().join("attachments").join("eng");
    assert!(att_eng.exists(), "attachment dir missing");
    let item_dirs: Vec<_> = std::fs::read_dir(&att_eng).unwrap().map(|e| e.unwrap().path()).collect();
    assert_eq!(item_dirs.len(), 1, "expected exactly one item attachment dir");
    let blobs: Vec<_> = std::fs::read_dir(&item_dirs[0]).unwrap().map(|e| e.unwrap().path()).collect();
    assert_eq!(blobs.len(), 1, "expected exactly one attachment blob");
    assert_eq!(blobs[0].extension().and_then(|e| e.to_str()), Some("enc"), "blob must be .enc");

    let got = f.run(&["org", "get", "Q3 Memo"]);
    let stdout = String::from_utf8_lossy(&got.stdout);
    assert!(stdout.contains("Filename: note.txt"), "get missing filename: {stdout}");
    // Staging proof: nothing attachment-related left uncommitted.
    assert!(!git_porcelain(f.vault_str()).contains("attachments/"), "unstaged attachment after add");
}

#[test]
fn org_purge_document_removes_attachment_dir() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("eng");
    let srcdir = TempDir::new().unwrap();
    let src = srcdir.path().join("d.bin");
    std::fs::write(&src, b"bytes").unwrap();
    assert!(f.run(&["org", "add", "document", "--collection", "eng",
                    "--title", "Doc", "--file", src.to_str().unwrap()]).status.success());

    let att_eng = f.vault_path().join("attachments").join("eng");
    assert!(std::fs::read_dir(&att_eng).unwrap().next().is_some(), "attachment must exist after add");

    assert!(f.run(&["org", "rm", "Doc"]).status.success(), "rm");
    let out = f.run(&["org", "purge", "Doc"]);
    assert!(out.status.success(), "purge: {}", String::from_utf8_lossy(&out.stderr));

    let empty = !att_eng.exists() || std::fs::read_dir(&att_eng).unwrap().next().is_none();
    assert!(empty, "attachment dir should be gone after purge");
    assert!(!git_porcelain(f.vault_str()).contains("attachments/"), "unstaged attachment removal after purge");
}

#[test]
fn org_edit_document_replaces_attachment_and_stages_cleanly() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("eng");
    let srcdir = TempDir::new().unwrap();
    let a = srcdir.path().join("a.txt");
    std::fs::write(&a, b"version A").unwrap();
    assert!(f.run(&["org", "add", "document", "--collection", "eng",
                    "--title", "Spec", "--file", a.to_str().unwrap()]).status.success());

    let b = srcdir.path().join("b.md");
    std::fs::write(&b, b"version B has different content").unwrap();
    let out = f.run(&["org", "edit", "Spec", "--file", b.to_str().unwrap()]);
    assert!(out.status.success(), "edit --file: {}", String::from_utf8_lossy(&out.stderr));

    let got = String::from_utf8_lossy(&f.run(&["org", "get", "Spec"]).stdout).to_string();
    assert!(got.contains("Filename: b.md"), "get should show new filename: {got}");
    assert!(!got.contains("a.txt"), "old filename should be gone: {got}");

    // Old blob replaced, not accumulated: exactly one blob remains.
    let att_eng = f.vault_path().join("attachments").join("eng");
    let item_dirs: Vec<_> = std::fs::read_dir(&att_eng).unwrap().map(|e| e.unwrap().path()).collect();
    assert_eq!(item_dirs.len(), 1, "one item attachment dir");
    let blobs = std::fs::read_dir(&item_dirs[0]).unwrap().count();
    assert_eq!(blobs, 1, "old blob must be replaced, not accumulated");

    // The key staging proof: no orphaned old blob / unstaged new blob.
    assert!(!git_porcelain(f.vault_str()).contains("attachments/"),
            "edit-replace left attachment changes unstaged (incomplete git add)");
}

#[test]
fn org_edit_file_on_non_document_is_rejected() {
    let f = OrgFixture::new();
    f.create_collection_and_grant("eng");
    assert!(f.run(&["org", "add", "login", "--collection", "eng",
                    "--title", "Site", "--password", "p"]).status.success());
    let srcdir = TempDir::new().unwrap();
    let x = srcdir.path().join("x.txt");
    std::fs::write(&x, b"nope").unwrap();

    let out = f.run(&["org", "edit", "Site", "--file", x.to_str().unwrap()]);
    assert!(!out.status.success(), "--file on a Login must be rejected");
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(stderr.contains("--file is only valid"), "unexpected error: {stderr}");
}

#[test]
fn org_add_document_into_ungranted_collection_is_denied() {
    let f = OrgFixture::new();
    // Collection exists but the owner is NOT granted.
    assert!(f.run(&["org", "create-collection", "secret", "--name", "Secret"]).status.success(),
            "create-collection");
    let srcdir = TempDir::new().unwrap();
    let src = srcdir.path().join("f.txt");
    std::fs::write(&src, b"data").unwrap();

    let out = f.run(&["org", "add", "document", "--collection", "secret",
                      "--title", "X", "--file", src.to_str().unwrap()]);
    assert!(!out.status.success(), "ungranted document add must fail");
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(stderr.contains("access denied") || stderr.contains("grant"), "unexpected error: {stderr}");
    // Grant is enforced before any attachment is written.
    assert!(!f.vault_path().join("attachments").join("secret").exists(),
            "no attachment dir should exist on a denied add");
}
