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

/// A device home + an org vault. A second device can be wired for multi-member.
struct Dev {
    xdg: PathBuf,
    _config: TempDir,
}

impl Dev {
    fn new(name: &str) -> Self {
        let config = TempDir::new().unwrap();
        let xdg = config.path().to_path_buf();
        let devices = xdg.join("relicario").join("devices").join(name);
        std::fs::create_dir_all(&devices).unwrap();
        let keyfile = devices.join("signing.key");
        let st = 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!(st.success());
        std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
        std::fs::write(xdg.join("relicario").join("devices").join("current"), format!("{name}\n")).unwrap();
        Dev { xdg, _config: config }
    }

    fn pubkey(&self, name: &str) -> String {
        std::fs::read_to_string(
            self.xdg.join("relicario").join("devices").join(name).join("signing.pub"),
        ).unwrap().trim().to_string()
    }

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

fn owner_member_id(vault: &Path) -> String {
    let s = std::fs::read_to_string(vault.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()
}

/// Set up an org with the owner granted `prod` and one login item in it.
fn setup_with_item() -> (Dev, TempDir, String) {
    let dev = Dev::new("laptop");
    let vault = TempDir::new().unwrap();
    let v = vault.path();
    assert!(dev.run(v, &["org", "init", "--dir", v.to_str().unwrap(), "--name", "Acme"]).status.success());
    let owner = owner_member_id(v);
    assert!(dev.run(v, &["org", "create-collection", "prod", "--name", "Prod"]).status.success());
    assert!(dev.run(v, &["org", "grant", &owner, "prod"]).status.success());
    assert!(dev.run(v, &[
        "org", "add", "login", "--collection", "prod",
        "--title", "GitHub", "--username", "alice", "--password", "hunter2",
    ]).status.success());
    (dev, vault, owner)
}

// (b) audit --format json parses + has expected actions.
#[test]
fn audit_format_json_is_valid_and_has_actions() {
    let (dev, vault, _owner) = setup_with_item();
    let out = dev.run(vault.path(), &["org", "audit", "--format", "json"]);
    assert!(out.status.success(), "audit json: {}", String::from_utf8_lossy(&out.stderr));
    let stdout = String::from_utf8_lossy(&out.stdout);
    let events: serde_json::Value = serde_json::from_str(&stdout).expect("audit json must parse");
    let arr = events.as_array().expect("array");
    let actions: Vec<&str> = arr.iter()
        .filter_map(|e| e["action"].as_str())
        .collect();
    assert!(actions.contains(&"org-init"), "actions: {actions:?}");
    assert!(actions.contains(&"collection-create"), "actions: {actions:?}");
    assert!(actions.contains(&"item-create"), "actions: {actions:?}");
    // Honest signer attribution: none of these should be TAMPERED (signer == trailer).
    assert!(arr.iter().all(|e| e["tampered"] == serde_json::Value::Bool(false)));
}

// (a) a forged-trailer commit is flagged TAMPERED.
#[test]
fn forged_trailer_commit_is_flagged_tampered() {
    let (dev, vault, owner) = setup_with_item();
    let v = vault.path();

    // Hand-craft a SIGNED commit whose trailer CLAIMS a different actor id than
    // the real signer. We reuse the org repo's own signing config (set by
    // `org init`), so the commit verifies — but the trailer lies.
    std::fs::write(v.join("decoy.txt"), "x").unwrap();
    let git = |args: &[&str]| {
        Command::new("git").current_dir(v).args(args)
            .env("XDG_CONFIG_HOME", &dev.xdg)
            .output().unwrap()
    };
    assert!(git(&["add", "decoy.txt"]).status.success());
    let forged_msg = format!(
        "forged\n\nRelicario-Actor: impostor ffffffffffffffff\nRelicario-Action: item-update\nRelicario-Member: {owner}"
    );
    // commit -S uses the repo's configured signing key (the real owner key).
    let c = git(&["commit", "-S", "-m", &forged_msg]);
    assert!(c.status.success(), "forged commit: {}", String::from_utf8_lossy(&c.stderr));

    let out = dev.run(v, &["org", "audit", "--format", "json"]);
    let events: serde_json::Value =
        serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
    let forged = events.as_array().unwrap().iter()
        .find(|e| e["action"] == "item-update")
        .expect("forged item-update event present");
    // Trailer claims ffff... but the verified signer is the owner → TAMPERED.
    assert_eq!(forged["tampered"], serde_json::Value::Bool(true));
    assert_eq!(forged["actor_id"].as_str(), Some(owner.as_str()));
}

// (c) concurrent rotate-key aborts with the exact spec error string.
#[test]
fn concurrent_rotate_key_aborts_with_spec_string() {
    let (dev, vault, _owner) = setup_with_item();
    let origin = TempDir::new().unwrap();
    let v = vault.path();
    let git = |args: &[&str]| Command::new("git").current_dir(v).args(args)
        .env("XDG_CONFIG_HOME", &dev.xdg).output().unwrap();

    // Make a bare origin and push, so a divergent upstream can be simulated.
    assert!(Command::new("git").args(["init", "--bare", origin.path().to_str().unwrap()])
        .output().unwrap().status.success());
    assert!(git(&["remote", "add", "origin", origin.path().to_str().unwrap()]).status.success());
    assert!(git(&["push", "-u", "origin", "HEAD"]).status.success());

    // Diverge upstream: a second clone commits + pushes, writing to a SHARED file
    // so that `git pull --rebase` will hit a merge conflict (add/add or edit/edit)
    // and exit non-zero — which is how run_rotate_key detects a concurrent rotation.
    let clone2 = TempDir::new().unwrap();
    assert!(Command::new("git")
        .args(["clone", origin.path().to_str().unwrap(), clone2.path().to_str().unwrap()])
        .output().unwrap().status.success());
    std::fs::write(clone2.path().join("conflict.txt"), "upstream-version").unwrap();
    for a in [&["add", "conflict.txt"][..], &["-c", "user.email=u@u", "-c", "user.name=u", "commit", "-m", "upstream"][..], &["push", "origin", "HEAD:master"][..], &["push", "origin", "HEAD:main"][..]] {
        let _ = Command::new("git").current_dir(clone2.path()).args(a).output();
    }
    // Local also writes conflict.txt with different content → add/add conflict on pull.
    std::fs::write(v.join("conflict.txt"), "local-version").unwrap();
    assert!(git(&["add", "conflict.txt"]).status.success());
    assert!(git(&["-c", "commit.gpgsign=false", "commit", "-m", "local"]).status.success());

    let out = dev.run(v, &["org", "rotate-key"]);
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(!out.status.success(), "rotate-key should abort on a concurrent rotation");
    assert!(
        stderr.contains("Concurrent key rotation detected — pull and re-run org rotate-key."),
        "missing spec error string: {stderr}"
    );
}

// (d) remove-member → rotate-key → old clone cannot decrypt; remaining member can.
#[test]
fn removed_member_clone_cannot_decrypt_after_rotation() {
    // Owner laptop sets up the org + a second member "bob".
    let (owner_dev, vault, _owner) = setup_with_item();
    let v = vault.path();
    let bob = Dev::new("bob-laptop");
    let bob_pub = bob.pubkey("bob-laptop");

    // Owner adds Bob and grants him prod.
    assert!(owner_dev.run(v, &["org", "add-member", "--key", &bob_pub, "--name", "Bob", "--role", "member"]).status.success());
    let members = std::fs::read_to_string(v.join("members.json")).unwrap();
    let mv: serde_json::Value = serde_json::from_str(&members).unwrap();
    let bob_id = mv["members"].as_array().unwrap().iter()
        .find(|m| m["display_name"] == "Bob").unwrap()["member_id"].as_str().unwrap().to_string();
    assert!(owner_dev.run(v, &["org", "grant", &bob_id, "prod"]).status.success());

    // Bob clones the vault dir (his device, his key blob is present).
    // `cp -r /vault /dst/` places contents at `/dst/<vault_basename>/` — use that
    // sub-path, not the TempDir root, as the vault for Bob's commands.
    let bob_clone = TempDir::new().unwrap();
    let vault_basename = v.file_name().unwrap();
    let cp = Command::new("cp").args(["-r", v.to_str().unwrap(), bob_clone.path().to_str().unwrap()]).output().unwrap();
    assert!(cp.status.success());
    let bob_vault = bob_clone.path().join(vault_basename);
    // Bob can read the item BEFORE removal.
    let pre = bob.run(&bob_vault, &["org", "get", "GitHub", "--show"]);
    assert!(String::from_utf8_lossy(&pre.stdout).contains("hunter2"), "bob should read pre-removal");

    // Owner removes Bob and rotates the key in the live vault.
    assert!(owner_dev.run(v, &["org", "remove-member", &bob_id]).status.success());
    assert!(owner_dev.run(v, &["org", "rotate-key"]).status.success());

    // Owner (remaining member) can still decrypt in the live vault.
    let owner_get = owner_dev.run(v, &["org", "get", "GitHub", "--show"]);
    assert!(String::from_utf8_lossy(&owner_get.stdout).contains("hunter2"), "owner must still read");

    // Copy the rotated item + manifest into Bob's stale clone (simulating a
    // pull) — his OLD key blob can no longer unwrap the rotated org key.
    let _ = Command::new("cp").args(["-r",
        v.join("items").to_str().unwrap(), bob_vault.to_str().unwrap()]).output();
    let _ = std::fs::copy(v.join("manifest.enc"), bob_vault.join("manifest.enc"));
    let post = bob.run(&bob_vault, &["org", "get", "GitHub", "--show"]);
    assert!(!post.status.success() || !String::from_utf8_lossy(&post.stdout).contains("hunter2"),
        "removed member must NOT decrypt post-rotation: {}", String::from_utf8_lossy(&post.stdout));
}
