//! `relicario org` subcommands for multi-user org vault management.

use std::fs;
use std::path::Path;

use anyhow::{Context, Result};
use relicario_core::{
    generate_org_key, wrap_org_key,
    CollectionDef, Item, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta,
    OrgRole, OrgMember,
    encrypt_org_manifest,
};

use crate::org_session::atomic_write;
use crate::commands::item_build as ib;

pub fn run_init(dir: &Path, name: &str) -> Result<()> {
    // Create directory structure
    fs::create_dir_all(dir.join("items")).context("create items/")?;
    fs::create_dir_all(dir.join("keys")).context("create keys/")?;

    // Get caller's device info
    let device_pubkey = crate::device::current_device_pubkey()
        .context("read device key — run `relicario device add` first")?;

    // Generate org master key
    let org_key = generate_org_key();

    // Wrap org key to caller's device key
    let wrapped = wrap_org_key(&org_key, &device_pubkey)
        .context("wrap org key to device key")?;

    // Create initial members.json with caller as owner
    let caller_id = MemberId::new();
    let now = relicario_core::now_unix();
    let member = OrgMember {
        member_id: caller_id.clone(),
        display_name: whoami(),
        role: OrgRole::Owner,
        ed25519_pubkey: device_pubkey,
        collections: vec![],
        added_at: now,
        added_by: caller_id.clone(),
    };
    let mut members = OrgMembers::new();
    members.members.push(member);

    // Write wrapped key
    let key_path = dir.join("keys").join(format!("{}.enc", caller_id.as_str()));
    fs::write(&key_path, &wrapped).context("write caller key blob")?;

    // Write org.json
    let meta = OrgMeta::new(name.to_string());
    let meta_json = serde_json::to_string_pretty(&meta)?;
    atomic_write(&dir.join("org.json"), meta_json.as_bytes())?;

    // Write members.json
    let members_json = serde_json::to_string_pretty(&members)?;
    atomic_write(&dir.join("members.json"), members_json.as_bytes())?;

    // Write collections.json (empty)
    let collections = OrgCollections::new();
    let coll_json = serde_json::to_string_pretty(&collections)?;
    atomic_write(&dir.join("collections.json"), coll_json.as_bytes())?;

    // Write empty manifest.enc
    let manifest = OrgManifest::new();
    let manifest_bytes = encrypt_org_manifest(&manifest, &org_key)?;
    atomic_write(&dir.join("manifest.enc"), &manifest_bytes)?;

    // git init, then configure THIS repo to sign commits with the active device
    // key. Org commits must be signed; the pre-receive hook verifies every one.
    crate::helpers::git_run(dir, &["init"], "git init")?;
    let device_name = crate::device::current_device()?
        .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
    crate::device::configure_git_signing(dir, &device_name)
        .context("configure org repo signing")?;

    // Stage everything and make the signed bootstrap commit via org_git_run
    // (which does NOT disable signing, unlike helpers::git_run).
    crate::org_session::org_git_run(dir, &["add", "."], "git add")?;
    let commit_msg = format!(
        "init: org vault \"{name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: org-init",
        members.members[0].display_name,
        caller_id.as_str()
    );
    crate::org_session::org_git_run(dir, &["commit", "-m", &commit_msg], "git commit")?;

    println!("Org vault initialized at {}", dir.display());
    println!("Your member ID: {}", caller_id.as_str());
    Ok(())
}

fn whoami() -> String {
    std::env::var("USER")
        .or_else(|_| std::env::var("USERNAME"))
        .unwrap_or_else(|_| "unknown".into())
}

pub fn run_add_member(
    dir: &Path,
    pubkey: &str,
    name: &str,
    role: OrgRole,
) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    if !caller.role.can_manage_members() {
        anyhow::bail!("only owners and admins can add members");
    }
    // Privilege-escalation guard: only an owner may create an owner or admin.
    if matches!(role, OrgRole::Owner | OrgRole::Admin) && !caller.role.can_manage_owners() {
        anyhow::bail!("only owners can add members with the owner or admin role");
    }

    let mut members = vault.load_members()?;

    // Check pubkey not already present
    if members.members.iter().any(|m| m.ed25519_pubkey.trim() == pubkey.trim()) {
        anyhow::bail!("this public key is already registered in the org");
    }

    let new_id = MemberId::new();
    let now = relicario_core::now_unix();
    let wrapped = wrap_org_key(vault.key(), pubkey)
        .context("wrap org key to new member's key")?;

    fs::write(vault.member_key_path(&new_id), &wrapped)
        .context("write member key blob")?;

    members.members.push(OrgMember {
        member_id: new_id.clone(),
        display_name: name.to_string(),
        role,
        ed25519_pubkey: pubkey.trim().to_string(),
        collections: vec![],
        added_at: now,
        added_by: caller.member_id.clone(),
    });
    vault.save_members(&members)?;

    let commit_msg = format!(
        "org: add member \"{name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: member-add\nRelicario-Member: {}",
        caller.display_name, caller.member_id.as_str(), new_id.as_str()
    );
    crate::org_session::org_git_run(
        &vault.root,
        &["add", "members.json", &format!("keys/{}.enc", new_id.as_str())],
        "git add",
    )?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;

    println!("Added {} ({})", name, new_id.as_str());
    Ok(())
}

pub fn run_remove_member(dir: &Path, member_id_prefix: &str) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    if !caller.role.can_manage_members() {
        anyhow::bail!("only owners and admins can remove members");
    }

    let mut members = vault.load_members()?;
    let target_id = resolve_member_id(&members, member_id_prefix)?;

    let target = members.find_by_id(&target_id).unwrap();
    if target.role == OrgRole::Owner && !caller.role.can_manage_owners() {
        anyhow::bail!("only owners can remove other owners");
    }
    let target_name = target.display_name.clone();

    // Delete key blob
    let key_path = vault.member_key_path(&target_id);
    if key_path.exists() { fs::remove_file(&key_path).context("delete key blob")?; }

    members.members.retain(|m| m.member_id != target_id);
    vault.save_members(&members)?;

    let commit_msg = format!(
        "org: remove member \"{target_name}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: member-remove\nRelicario-Member: {}",
        caller.display_name, caller.member_id.as_str(), target_id.as_str()
    );
    crate::org_session::org_git_run(
        &vault.root,
        &["add", "members.json", &format!("keys/{}.enc", target_id.as_str())],
        "git add",
    )?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;

    eprintln!("⚠ Run `relicario org rotate-key --dir {}` to complete revocation.", vault.root.display());
    println!("Removed {}", target_name);
    Ok(())
}

pub fn run_set_role(dir: &Path, member_id_prefix: &str, role: OrgRole) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;

    let mut members = vault.load_members()?;
    let target_id = resolve_member_id(&members, member_id_prefix)?;

    if matches!(role, OrgRole::Admin | OrgRole::Owner) && !caller.role.can_manage_owners() {
        anyhow::bail!("only owners can promote to admin or owner");
    }
    if !caller.role.can_manage_members() {
        anyhow::bail!("only owners and admins can change roles");
    }

    let target = members.find_by_id_mut(&target_id)
        .ok_or_else(|| anyhow::anyhow!("member not found"))?;
    let old_role = target.role;
    target.role = role;
    vault.save_members(&members)?;

    let commit_msg = format!(
        "org: set role {} → {:?}\n\nRelicario-Actor: {} {}\nRelicario-Action: member-role-change\nRelicario-Member: {}",
        target_id.as_str(), role,
        caller.display_name, caller.member_id.as_str(),
        target_id.as_str()
    );
    crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;

    println!("Changed role {:?} → {:?}", old_role, role);
    Ok(())
}

pub fn run_create_collection(dir: &Path, slug: &str, display_name: &str) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    if !caller.role.can_manage_members() {
        anyhow::bail!("only owners and admins can create collections");
    }

    let mut collections = vault.load_collections()?;
    if collections.contains_slug(slug) {
        anyhow::bail!("collection `{slug}` already exists");
    }
    if slug.is_empty() || slug.contains('/') || slug.contains('.') {
        anyhow::bail!("invalid slug `{slug}` — no slashes or dots, no empty string");
    }

    collections.collections.push(CollectionDef {
        slug: slug.to_string(),
        display_name: display_name.to_string(),
        created_by: caller.member_id.clone(),
        created_at: relicario_core::now_unix(),
    });
    vault.save_collections(&collections)?;

    let commit_msg = format!(
        "org: create collection \"{slug}\"\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-create\nRelicario-Collection: {slug}",
        caller.display_name, caller.member_id.as_str()
    );
    crate::org_session::org_git_run(&vault.root, &["add", "collections.json"], "git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;

    println!("Created collection `{slug}`");
    Ok(())
}

pub fn run_grant(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    if !caller.role.can_manage_members() {
        anyhow::bail!("only owners and admins can grant collection access");
    }

    let collections = vault.load_collections()?;
    if !collections.contains_slug(slug) {
        anyhow::bail!("collection `{slug}` does not exist — create it first");
    }

    let mut members = vault.load_members()?;
    let target_id = resolve_member_id(&members, member_id_prefix)?;
    let target = members.find_by_id_mut(&target_id).unwrap();
    if target.collections.contains(&slug.to_string()) {
        anyhow::bail!("member already has access to `{slug}`");
    }
    target.collections.push(slug.to_string());
    vault.save_members(&members)?;

    let commit_msg = format!(
        "org: grant {slug} to {}\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-grant\nRelicario-Collection: {slug}\nRelicario-Member: {}",
        target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str()
    );
    crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;

    println!("Granted `{slug}` to {}", target_id.as_str());
    Ok(())
}

pub fn run_revoke(dir: &Path, member_id_prefix: &str, slug: &str) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    if !caller.role.can_manage_members() {
        anyhow::bail!("only owners and admins can revoke collection access");
    }

    let mut members = vault.load_members()?;
    let target_id = resolve_member_id(&members, member_id_prefix)?;
    let target = members.find_by_id_mut(&target_id).unwrap();
    if !target.collections.contains(&slug.to_string()) {
        anyhow::bail!("member does not have access to `{slug}`");
    }
    target.collections.retain(|s| s != slug);
    vault.save_members(&members)?;

    let commit_msg = format!(
        "org: revoke {slug} from {}\n\nRelicario-Actor: {} {}\nRelicario-Action: collection-revoke\nRelicario-Collection: {slug}\nRelicario-Member: {}",
        target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str()
    );
    crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;

    println!("Revoked `{slug}` from {}", target_id.as_str());
    Ok(())
}

/// Resolve a member_id prefix (or full ID) to a MemberId.
fn resolve_member_id(members: &OrgMembers, prefix: &str) -> Result<MemberId> {
    let hits: Vec<_> = members.members.iter()
        .filter(|m| m.member_id.as_str().starts_with(prefix))
        .collect();
    match hits.len() {
        0 => anyhow::bail!("no member matches `{prefix}`"),
        1 => Ok(hits[0].member_id.clone()),
        _ => anyhow::bail!("ambiguous prefix `{prefix}` — {} matches", hits.len()),
    }
}

pub fn run_rotate_key(dir: &Path) -> Result<()> {
    // Pull latest state first to detect a concurrent rotation. We must
    // distinguish three outcomes:
    //   * success                       -> proceed
    //   * no upstream / no remote        -> local-only org, proceed
    //   * non-fast-forward / conflict    -> concurrent rotation, ABORT
    let pull = std::process::Command::new("git")
        .current_dir(dir)
        .args(["pull", "--rebase"])
        .output()
        .context("spawn git pull --rebase")?;
    if !pull.status.success() {
        let stderr = String::from_utf8_lossy(&pull.stderr);
        let no_upstream = stderr.contains("no tracking information")
            || stderr.contains("There is no tracking information")
            || stderr.contains("does not appear to be a git repository")
            || stderr.contains("Could not read from remote repository")
            || stderr.contains("No remote repository specified");
        if no_upstream {
            eprintln!("Note: no upstream configured; proceeding with local state.");
        } else {
            // Best-effort: leave the working tree clean for the retry.
            let _ = std::process::Command::new("git")
                .current_dir(dir)
                .args(["rebase", "--abort"])
                .output();
            anyhow::bail!(
                "Concurrent key rotation detected — pull and re-run org rotate-key."
            );
        }
    }

    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    if !caller.role.can_manage_owners() {
        anyhow::bail!("only owners can rotate the org master key");
    }

    let members = vault.load_members()?;
    let new_org_key = relicario_core::generate_org_key();

    // Re-wrap the org key for every current member.
    let mut staged_paths: Vec<String> = Vec::new();
    for member in &members.members {
        let wrapped = wrap_org_key(&new_org_key, &member.ed25519_pubkey)
            .with_context(|| format!("wrap key for {}", member.display_name))?;
        let key_path = vault.member_key_path(&member.member_id);
        crate::org_session::atomic_write(&key_path, &wrapped)
            .with_context(|| format!("write key for {}", member.display_name))?;
        staged_paths.push(format!("keys/{}.enc", member.member_id.as_str()));
    }

    // Re-encrypt EVERY item blob under the new key. Items live collection-scoped
    // at items/<collection-slug>/<id>.enc. Decrypt with the old key (held in the
    // open vault session) and re-encrypt with the new one, in place. Without this
    // a removed member who kept the old key + a clone could still decrypt every
    // pre-rotation item.
    let items_root = vault.root().join("items");
    if items_root.is_dir() {
        for slug_entry in fs::read_dir(&items_root).context("read items/")? {
            let slug_entry = slug_entry.context("read items/ entry")?;
            let slug_dir = slug_entry.path();
            if !slug_dir.is_dir() {
                continue;
            }
            let slug = slug_entry.file_name().to_string_lossy().to_string();
            for item_entry in fs::read_dir(&slug_dir)
                .with_context(|| format!("read items/{slug}/"))?
            {
                let item_entry = item_entry.context("read item entry")?;
                let item_path = item_entry.path();
                if item_path.extension().and_then(|e| e.to_str()) != Some("enc") {
                    continue;
                }
                let old_bytes = fs::read(&item_path)
                    .with_context(|| format!("read {}", item_path.display()))?;
                let item = relicario_core::decrypt_item(&old_bytes, vault.key())
                    .with_context(|| format!("decrypt {}", item_path.display()))?;
                let new_bytes = relicario_core::encrypt_item(&item, &new_org_key)
                    .with_context(|| format!("re-encrypt {}", item_path.display()))?;
                crate::org_session::atomic_write(&item_path, &new_bytes)?;
                let file_name = item_entry.file_name().to_string_lossy().to_string();
                staged_paths.push(format!("items/{slug}/{file_name}"));
            }
        }
    }

    // Re-encrypt the manifest with the new key.
    let manifest = vault.load_manifest()?;
    let new_manifest_bytes = relicario_core::encrypt_org_manifest(&manifest, &new_org_key)?;
    crate::org_session::atomic_write(&vault.manifest_path(), &new_manifest_bytes)?;
    staged_paths.push("manifest.enc".to_string());

    // Commit
    let mut add_args = vec!["add"];
    let path_refs: Vec<&str> = staged_paths.iter().map(|s| s.as_str()).collect();
    add_args.extend_from_slice(&path_refs);
    crate::org_session::org_git_run(&vault.root, &add_args, "git add")?;

    let commit_msg = format!(
        "org: rotate org master key\n\nRelicario-Actor: {} {}\nRelicario-Action: key-rotate",
        caller.display_name, caller.member_id.as_str()
    );
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;

    println!(
        "Key rotated. {} member key(s) re-wrapped; all item blobs + manifest re-encrypted.",
        members.members.len()
    );
    Ok(())
}

pub fn run_transfer_ownership(dir: &Path, member_id_prefix: &str, keep_owner: bool) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    if !caller.role.can_manage_owners() {
        anyhow::bail!("only an owner can transfer ownership");
    }
    let mut members = vault.load_members()?;
    let target_id = resolve_member_id(&members, member_id_prefix)?;
    if target_id == caller.member_id {
        anyhow::bail!("you are already the owner");
    }
    // Promote the target to Owner.
    {
        let target = members.find_by_id_mut(&target_id)
            .ok_or_else(|| anyhow::anyhow!("member not found"))?;
        target.role = OrgRole::Owner;
    }
    // Real transfer: also demote the CALLER to Admin, unless --keep-owner was
    // passed (explicit co-ownership). The spec says "owner → another member",
    // so demotion is the default.
    if !keep_owner {
        if let Some(me) = members.find_by_id_mut(&caller.member_id) {
            me.role = OrgRole::Admin;
        }
    }
    vault.save_members(&members)?;

    let mode = if keep_owner { "co-ownership (caller kept owner)" } else { "caller demoted to admin" };
    let commit_msg = format!(
        "org: transfer ownership to {} ({mode})\n\nRelicario-Actor: {} {}\nRelicario-Action: ownership-transfer\nRelicario-Member: {}",
        target_id.as_str(), caller.display_name, caller.member_id.as_str(), target_id.as_str()
    );
    crate::org_session::org_git_run(&vault.root, &["add", "members.json"], "git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
    if keep_owner {
        println!("Ownership shared with {} (you remain an owner).", target_id.as_str());
    } else {
        println!("Ownership transferred to {} (you are now an admin).", target_id.as_str());
    }
    Ok(())
}

pub fn run_delete_org(dir: &Path, confirm: bool) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    if !caller.role.can_manage_owners() {
        anyhow::bail!("only an owner can delete the org");
    }
    if !confirm {
        anyhow::bail!("refusing to delete org without --confirm");
    }
    let commit_msg = format!(
        "org: delete org\n\nRelicario-Actor: {} {}\nRelicario-Action: org-delete",
        caller.display_name, caller.member_id.as_str()
    );
    // Remove org files (the git history is retained as the audit record).
    for f in ["org.json", "members.json", "collections.json", "manifest.enc"] {
        let _ = fs::remove_file(vault.root.join(f));
    }
    let _ = std::fs::remove_dir_all(vault.root.join("items"));
    let _ = std::fs::remove_dir_all(vault.root.join("keys"));
    crate::org_session::org_git_run(&vault.root, &["add", "-A"], "git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "git commit")?;
    println!("Org deleted (git history retained as audit record).");
    Ok(())
}

pub fn run_status(dir: &Path) -> Result<()> {
    let root = crate::org_session::org_dir(Some(dir))?;

    let meta: relicario_core::OrgMeta = {
        let s = fs::read_to_string(root.join("org.json")).context("read org.json")?;
        serde_json::from_str(&s)?
    };
    let members: OrgMembers = {
        let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
        serde_json::from_str(&s)?
    };
    let collections: OrgCollections = {
        let s = fs::read_to_string(root.join("collections.json")).context("read collections.json")?;
        serde_json::from_str(&s)?
    };

    println!("Org: {} ({})", meta.display_name, meta.org_id.as_str());
    println!();
    println!("Members ({}):", members.members.len());
    for m in &members.members {
        let colls = if m.collections.is_empty() {
            "(no collections)".to_string()
        } else {
            m.collections.join(", ")
        };
        println!("  {:?}  {}  {}  [{}]", m.role, m.member_id.as_str(), m.display_name, colls);
    }
    println!();
    println!("Collections ({}):", collections.collections.len());
    for c in &collections.collections {
        println!("  {}  —  {}", c.slug, c.display_name);
    }
    Ok(())
}

#[derive(Debug, serde::Serialize)]
pub struct AuditEvent {
    pub commit: String,
    pub timestamp: String,
    /// Actor as resolved from the VERIFIED signing key (authoritative).
    pub actor_name: Option<String>,
    pub actor_id: Option<String>,
    /// Actor id as CLAIMED by the commit trailer (advisory; for tamper-checking).
    pub trailer_actor_id: Option<String>,
    pub action: Option<String>,
    pub collection: Option<String>,
    pub item_id: Option<String>,
    pub device_id: Option<String>,
    /// True when the trailer's claimed actor disagrees with the verified signer,
    /// or when no current member matches the signing key.
    pub tampered: bool,
}

fn parse_trailer_block(commit: &str, timestamp: &str, trailers: &str) -> AuditEvent {
    let mut ev = AuditEvent {
        commit: commit.to_string(),
        timestamp: timestamp.to_string(),
        actor_name: None,
        actor_id: None,
        trailer_actor_id: None,
        action: None,
        collection: None,
        item_id: None,
        device_id: None,
        tampered: false,
    };
    for line in trailers.lines() {
        let line = line.trim();
        if let Some(rest) = line.strip_prefix("Relicario-Actor:") {
            // Contract format: "<name> <member_id>" (member_id is the last token).
            let rest = rest.trim();
            if let Some((_name, id)) = rest.rsplit_once(' ') {
                ev.trailer_actor_id = Some(id.trim().to_string());
            } else if !rest.is_empty() {
                ev.trailer_actor_id = Some(rest.to_string());
            }
        } else if let Some(v) = line.strip_prefix("Relicario-Action:") {
            ev.action = Some(v.trim().to_string());
        } else if let Some(v) = line.strip_prefix("Relicario-Collection:") {
            ev.collection = Some(v.trim().to_string());
        } else if let Some(v) = line.strip_prefix("Relicario-Item:") {
            ev.item_id = Some(v.trim().to_string());
        } else if let Some(v) = line.strip_prefix("Relicario-Device:") {
            ev.device_id = Some(v.trim().to_string());
        }
    }
    ev
}

/// Resolve a commit's SSH signature fingerprint to a current member, mirroring
/// the pre-receive hook: build an allowed_signers from members.json, inject it
/// via GIT_CONFIG_*, run `git verify-commit --raw`, parse the SHA256: key from
/// stderr. Returns None if the commit is unsigned or the signer is not a member.
fn resolve_signer<'m>(
    root: &Path,
    commit: &str,
    members: &'m relicario_core::OrgMembers,
) -> Option<&'m relicario_core::OrgMember> {
    use std::io::Write;
    let mut tmp = tempfile::NamedTempFile::new().ok()?;
    for m in &members.members {
        let _ = writeln!(tmp, "relicario {}", m.ed25519_pubkey.trim());
    }
    let allowed_path = tmp.path();

    let output = std::process::Command::new("git")
        .current_dir(root)
        .args(["verify-commit", "--raw", commit])
        .env("GIT_CONFIG_COUNT", "1")
        .env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
        .env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
        .output()
        .ok()?;
    let stderr = String::from_utf8_lossy(&output.stderr);

    let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").ok()?;
    let fp = re.captures(&stderr)?.get(1)?.as_str().to_string();

    members.members.iter().find(|m| {
        relicario_core::fingerprint(&m.ed25519_pubkey).ok().as_deref() == Some(fp.as_str())
    })
}

pub fn run_audit(
    dir: &Path,
    since: Option<&str>,
    member_filter: Option<&str>,
    collection_filter: Option<&str>,
    action_filter: Option<&str>,
    format: &str,
) -> Result<()> {
    // Spec surface is `--format <table|json>` (default table). Accept only those.
    let json = match format {
        "json" => true,
        "table" => false,
        other => anyhow::bail!("unknown --format `{other}` — use table or json"),
    };
    let root = crate::org_session::org_dir(Some(dir))?;

    // members.json — needed to resolve each commit's verified signer to a member.
    let members: relicario_core::OrgMembers = {
        let s = fs::read_to_string(root.join("members.json")).context("read members.json")?;
        serde_json::from_str(&s).context("parse members.json")?
    };

    // git log framed with a record separator (%x1e, U+001E) PER COMMIT and a
    // field separator (%x1f, U+001F) between fields, so multi-line trailer
    // values cannot misalign record boundaries. Committer date (%cI), not
    // author date: it is what revocation/audit is anchored to.
    let fmt = "%x1e%H%x1f%cI%x1f%(trailers:only=true,unfold=true)";
    let mut args: Vec<String> = vec!["log".into(), format!("--format={fmt}")];
    if let Some(s) = since {
        args.push(format!("--since={s}"));
    }

    let output = std::process::Command::new("git")
        .current_dir(&root)
        .args(&args)
        .output()
        .context("git log")?;
    let log = String::from_utf8_lossy(&output.stdout);

    let mut events: Vec<AuditEvent> = Vec::new();
    for record in log.split('\u{1e}') {
        let record = record.trim_start_matches('\n');
        if record.trim().is_empty() {
            continue;
        }
        let mut fields = record.splitn(3, '\u{1f}');
        let commit = fields.next().unwrap_or("").trim();
        let ts = fields.next().unwrap_or("").trim();
        let trailers = fields.next().unwrap_or("");
        if commit.is_empty() {
            continue;
        }

        let mut ev = parse_trailer_block(commit, ts, trailers);
        if ev.action.is_none() {
            continue; // not an org commit
        }

        // Resolve the VERIFIED signer and attribute it as the authoritative actor.
        match resolve_signer(&root, commit, &members) {
            Some(m) => {
                ev.actor_name = Some(m.display_name.clone());
                ev.actor_id = Some(m.member_id.as_str().to_string());
                // Tampered if the trailer claims a different actor than the signer.
                if let Some(claimed) = ev.trailer_actor_id.as_deref() {
                    if claimed != m.member_id.as_str() {
                        ev.tampered = true;
                    }
                }
            }
            None => {
                // No current member matched the signature -> cannot trust the
                // trailer's claimed actor.
                ev.tampered = true;
            }
        }

        if let Some(mid) = member_filter {
            // Filter on the VERIFIED actor id, not the spoofable trailer.
            if ev.actor_id.as_deref() != Some(mid) {
                continue;
            }
        }
        if let Some(col) = collection_filter {
            if ev.collection.as_deref() != Some(col) {
                continue;
            }
        }
        if let Some(act) = action_filter {
            if ev.action.as_deref() != Some(act) {
                continue;
            }
        }
        events.push(ev);
    }

    if json {
        println!("{}", serde_json::to_string_pretty(&events)?);
    } else {
        println!("{:<44} {:<26} {:<20} {:<18} FLAG", "COMMIT", "TIMESTAMP", "ACTION", "ACTOR");
        for ev in &events {
            println!("{:<44} {:<26} {:<20} {:<18} {}",
                ev.commit,
                ev.timestamp,
                ev.action.as_deref().unwrap_or("-"),
                ev.actor_name.as_deref().unwrap_or("<unverified>"),
                if ev.tampered { "TAMPERED" } else { "" },
            );
        }
    }
    Ok(())
}

/// Item kinds `org add` supports. Secrets resolve via `--*-stdin` flags or an
/// interactive prompt inside the shared `item_build` builders.
pub enum OrgAddKind {
    Login {
        title: String,
        username: Option<String>,
        url: Option<String>,
        password: Option<String>,
        password_stdin: bool,
    },
    SecureNote {
        title: String,
        body: Option<String>,
        body_stdin: bool,
    },
    Identity {
        title: String,
        full_name: Option<String>,
        email: Option<String>,
        phone: Option<String>,
    },
    Card {
        title: String,
        holder: Option<String>,
        expiry: Option<String>,
        kind: String,
        number_stdin: bool,
        cvv_stdin: bool,
        pin_stdin: bool,
    },
    Key {
        title: String,
        label: Option<String>,
        algorithm: Option<String>,
        public_key: Option<String>,
        material_stdin: bool,
    },
    Totp {
        title: String,
        issuer: Option<String>,
        label: Option<String>,
        secret: Option<String>,
        secret_stdin: bool,
        period: u32,
        digits: u8,
        algorithm: String,
    },
    Document { title: String, file: std::path::PathBuf },
}

fn build_org_item(kind: OrgAddKind) -> Result<Item> {
    match kind {
        OrgAddKind::Login { title, username, url, password, password_stdin } => {
            ib::build_login(title, username, url, password, password_stdin, false, None)
        }
        OrgAddKind::SecureNote { title, body, body_stdin } => {
            ib::build_secure_note(title, body, body_stdin)
        }
        OrgAddKind::Identity { title, full_name, email, phone } => {
            ib::build_identity(title, full_name, email, phone, None)
        }
        OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin } => {
            ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)
        }
        OrgAddKind::Key { title, label, algorithm, public_key, material_stdin } => {
            ib::build_key(title, label, algorithm, public_key, material_stdin)
        }
        OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm } => {
            ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm)
        }
        OrgAddKind::Document { .. } => unreachable!("Document handled in run_add before build_org_item"),
    }
}

pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
    use crate::org_session::UnlockedOrgVault;

    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;

    // Slug must exist in collections.json…
    let collections = vault.load_collections()?;
    if !collections.contains_slug(collection) {
        anyhow::bail!("collection `{collection}` does not exist — create it with `relicario org create-collection`");
    }
    // …and the caller must hold a grant for it.
    UnlockedOrgVault::ensure_grant(&caller, collection)?;

    // Build the item; Document additionally yields an encrypted attachment to persist.
    let (mut item, attachment_rel): (relicario_core::Item, Option<String>) = match kind {
        OrgAddKind::Document { title, file } => {
            let (item, enc) = ib::build_document(
                title, file, vault.key(), crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?;
            let rel = vault.save_attachment(collection, &item.id, &enc)?;
            (item, Some(rel))
        }
        other => (build_org_item(other)?, None),
    };
    item.tags = tags;
    let item_rel = vault.save_item(collection, &item)?;

    // Upsert the manifest entry (collection slug stored plaintext inside the
    // encrypted manifest).
    let mut manifest = vault.load_manifest()?;
    upsert_org_entry(&mut manifest, &item, collection);
    vault.save_manifest(&manifest)?;

    let subject = format!(
        "org add: {} ({})",
        crate::helpers::sanitize_for_commit(&item.title),
        item.id.as_str()
    );
    let commit_msg = format!(
        "{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-create\nRelicario-Collection: {}\nRelicario-Item: {}",
        caller.display_name,
        caller.member_id.as_str(),
        collection,
        item.id.as_str()
    );
    let mut add_args: Vec<&str> = vec!["add", &item_rel, "manifest.enc"];
    if let Some(ref rel) = attachment_rel {
        add_args.insert(1, rel);
    }
    crate::org_session::org_git_run(&vault.root, &add_args, "org add: git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?;

    println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection);
    Ok(())
}

pub fn run_list(dir: &Path, trashed: bool) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    let manifest = vault.load_manifest()?;

    // filter_for_member restricts to the caller's granted collections.
    let visible = manifest.filter_for_member(&caller);

    let mut entries: Vec<_> = visible.entries.iter()
        .filter(|e| if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() })
        .collect();
    entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));

    if entries.is_empty() {
        eprintln!("(no items match)");
        return Ok(());
    }

    println!("{:<16}  {:<14}  {:<12}  TITLE", "ID", "TYPE", "COLLECTION");
    for e in entries {
        println!(
            "{:<16}  {:<14}  {:<12}  {}",
            e.id.as_str(),
            format!("{:?}", e.r#type),
            e.collection,
            e.title
        );
    }
    Ok(())
}

pub fn run_get(dir: &Path, query: &str, show: bool) -> Result<()> {
    use relicario_core::ItemCore;
    use zeroize::Zeroizing;

    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    let manifest = vault.load_manifest()?;
    let visible = manifest.filter_for_member(&caller);

    let entry = resolve_org_query(&visible, query)?;
    // Double-check the grant for the resolved collection (defense in depth).
    crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &entry.collection)?;

    let item = vault.load_item(&entry.collection, &entry.id)?;

    println!("ID:         {}", item.id.as_str());
    println!("Title:      {}", item.title);
    println!("Type:       {:?}", item.r#type);
    println!("Collection: {}", entry.collection);
    if !item.tags.is_empty() { println!("Tags:       {}", item.tags.join(", ")); }
    println!("Modified:   {}", crate::helpers::iso8601(item.modified));
    if let Some(t) = item.trashed_at { println!("Trashed:    {}", crate::helpers::iso8601(t)); }
    println!();

    let primary_secret: Option<Zeroizing<String>> = match &item.core {
        ItemCore::Login(l) => {
            if let Some(u) = &l.username { println!("Username: {u}"); }
            if let Some(u) = &l.url { println!("URL:      {u}"); }
            l.password.clone()
        }
        ItemCore::SecureNote(n) => {
            if show { println!("Body:\n{}", n.body.as_str()); }
            else { println!("Body:     ********"); }
            None
        }
        ItemCore::Identity(i) => {
            if let Some(v) = &i.full_name { println!("Name:     {v}"); }
            if let Some(v) = &i.email { println!("Email:    {v}"); }
            if let Some(v) = &i.phone { println!("Phone:    {v}"); }
            None
        }
        ItemCore::Card(c) => {
            if let Some(h) = &c.holder { println!("Holder:   {h}"); }
            c.number.clone()
        }
        ItemCore::Key(k) => {
            if let Some(l) = &k.label { println!("Label:    {l}"); }
            Some(k.key_material.clone())
        }
        ItemCore::Document(d) => {
            println!("Filename: {}", d.filename);
            println!("MIME:     {}", d.mime_type);
            None
        }
        ItemCore::Totp(t) => {
            if let Some(i) = &t.issuer { println!("Issuer:   {i}"); }
            if let Some(l) = &t.label { println!("Label:    {l}"); }
            None
        }
    };

    if let Some(secret) = primary_secret {
        if show {
            println!("Secret:   {}", secret.as_str());
        } else {
            println!("Secret:   ********  (use --show to reveal)");
        }
    }
    Ok(())
}

/// Resolve a query (exact id, else case-insensitive title substring) against an
/// already-grant-filtered manifest.
fn resolve_org_query<'a>(
    manifest: &'a relicario_core::OrgManifest,
    query: &str,
) -> Result<&'a relicario_core::OrgManifestEntry> {
    if let Some(entry) = manifest.entries.iter().find(|e| e.id.as_str() == query) {
        return Ok(entry);
    }
    let needle = query.to_lowercase();
    let hits: Vec<&relicario_core::OrgManifestEntry> = manifest.entries.iter()
        .filter(|e| e.title.to_lowercase().contains(&needle))
        .collect();
    match hits.len() {
        0 => anyhow::bail!("no item matches `{query}`"),
        1 => Ok(hits[0]),
        _ => {
            let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect();
            anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", "))
        }
    }
}

pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>, file: Option<std::path::PathBuf>) -> Result<()> {
    use relicario_core::time::now_unix;
    use relicario_core::ItemCore;

    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    let manifest = vault.load_manifest()?;
    let visible = manifest.filter_for_member(&caller);
    let entry = resolve_org_query(&visible, query)?;
    let collection = entry.collection.clone();
    let id = entry.id.clone();
    crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;

    let mut item = vault.load_item(&collection, &id)?;
    if file.is_some() && !matches!(item.core, ItemCore::Document(_)) {
        anyhow::bail!("--file is only valid when editing a Document item");
    }
    eprintln!(
        "Editing: {} ({}) — leave a prompt blank to keep the current value.",
        item.title,
        item.id.as_str()
    );
    if let Some(v) = crate::prompt::prompt_keep("Title", &item.title)? {
        item.title = v;
    }

    let history = &mut item.field_history;
    let mut doc_attachment_rel: Option<String> = None;
    let mut new_doc_attachments: Option<Vec<relicario_core::AttachmentRef>> = None;
    match &mut item.core {
        ItemCore::Login(l) => ib::edit_login(l, history, totp_qr)?,
        ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?,
        ItemCore::Identity(i) => ib::edit_identity(i)?,
        ItemCore::Card(c) => ib::edit_card(c, history)?,
        ItemCore::Key(k) => ib::edit_key(k, history)?,
        ItemCore::Document(d) => {
            if let Some(path) = &file {
                let (enc, filename, mime_type, size) = ib::encrypt_document_file(
                    path, vault.key(), crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?;
                vault.remove_item_attachments(&collection, &id)?;
                let rel = vault.save_attachment(&collection, &id, &enc)?;
                d.filename = filename.clone();
                d.mime_type = mime_type.clone();
                d.primary_attachment = enc.id.clone();
                new_doc_attachments = Some(vec![relicario_core::AttachmentRef {
                    id: enc.id,
                    filename,
                    mime_type,
                    size,
                    created: now_unix(),
                }]);
                doc_attachment_rel = Some(rel);
            } else {
                ib::edit_document_message();
            }
        }
        ItemCore::Totp(t) => ib::edit_totp(t, history)?,
    }
    if let Some(atts) = new_doc_attachments {
        item.attachments = atts;
    }

    item.modified = now_unix();
    let item_rel = vault.save_item(&collection, &item)?;
    let mut manifest = vault.load_manifest()?;
    upsert_org_entry(&mut manifest, &item, &collection);
    vault.save_manifest(&manifest)?;

    let subject = format!(
        "org edit: {} ({})",
        crate::helpers::sanitize_for_commit(&item.title),
        item.id.as_str()
    );
    let commit_msg = format!(
        "{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}",
        caller.display_name,
        caller.member_id.as_str(),
        collection,
        item.id.as_str()
    );
    let mut add_args: Vec<&str> = vec!["add", &item_rel, "manifest.enc"];
    let att_dir_rel;
    if doc_attachment_rel.is_some() {
        att_dir_rel = format!("attachments/{}/{}", collection, id.as_str());
        add_args.push(&att_dir_rel);
    }
    crate::org_session::org_git_run(&vault.root, &add_args, "org edit: git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?;
    println!("Updated {} ({}) in `{}`", item.title, item.id.as_str(), collection);
    Ok(())
}

/// Resolve a query to (collection, item) with grant enforcement. Used by the
/// trash-lifecycle commands.
fn open_org_item(
    vault: &crate::org_session::UnlockedOrgVault,
    caller: &relicario_core::OrgMember,
    query: &str,
) -> Result<(String, relicario_core::Item)> {
    let manifest = vault.load_manifest()?;
    let visible = manifest.filter_for_member(caller);
    let entry = resolve_org_query(&visible, query)?;
    let collection = entry.collection.clone();
    let id = entry.id.clone();
    crate::org_session::UnlockedOrgVault::ensure_grant(caller, &collection)?;
    let item = vault.load_item(&collection, &id)?;
    Ok((collection, item))
}

pub fn run_rm(dir: &Path, query: &str) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    let (collection, mut item) = open_org_item(&vault, &caller, query)?;

    item.soft_delete();
    let item_rel = vault.save_item(&collection, &item)?;
    let mut manifest = vault.load_manifest()?;
    upsert_org_entry(&mut manifest, &item, &collection);
    vault.save_manifest(&manifest)?;

    let commit_msg = format!(
        "org trash: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-delete\nRelicario-Collection: {}\nRelicario-Item: {}",
        crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(),
        caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
    );
    crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org rm: git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org rm: git commit")?;
    println!("Moved to trash: {}", item.title);
    Ok(())
}

pub fn run_restore(dir: &Path, query: &str) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    let (collection, mut item) = open_org_item(&vault, &caller, query)?;

    item.restore();
    let item_rel = vault.save_item(&collection, &item)?;
    let mut manifest = vault.load_manifest()?;
    upsert_org_entry(&mut manifest, &item, &collection);
    vault.save_manifest(&manifest)?;

    let commit_msg = format!(
        "org restore: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-restore\nRelicario-Collection: {}\nRelicario-Item: {}",
        crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(),
        caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
    );
    crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org restore: git add")?;
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org restore: git commit")?;
    println!("Restored: {}", item.title);
    Ok(())
}

pub fn run_purge(dir: &Path, query: &str) -> Result<()> {
    let vault = crate::org_session::open_org_vault(Some(dir))?;
    let caller = vault.current_member()?;
    let (collection, item) = open_org_item(&vault, &caller, query)?;
    let title = item.title.clone();
    let id = item.id.clone();

    // Remove the blob from disk, drop the manifest entry, stage with git rm.
    vault.remove_item(&collection, &id)?;
    vault.remove_item_attachments(&collection, &id)?;
    let mut manifest = vault.load_manifest()?;
    manifest.entries.retain(|e| e.id != id);
    vault.save_manifest(&manifest)?;

    let item_rel = format!("items/{}/{}.enc", collection, id.as_str());
    let att_dir_rel = format!("attachments/{}/{}", collection, id.as_str());
    crate::helpers::git_rm(&vault.root, &[item_rel, att_dir_rel], "org purge: git rm")?;
    crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?;

    let commit_msg = format!(
        "org purge: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-purge\nRelicario-Collection: {}\nRelicario-Item: {}",
        crate::helpers::sanitize_for_commit(&title), id.as_str(),
        caller.display_name, caller.member_id.as_str(), collection, id.as_str()
    );
    crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org purge: git commit")?;
    println!("Purged: {title}");
    Ok(())
}

/// Insert-or-replace an `OrgManifestEntry` mirroring the personal-vault
/// `Manifest::upsert`. Keyed by item id.
fn upsert_org_entry(
    manifest: &mut relicario_core::OrgManifest,
    item: &Item,
    collection: &str,
) {
    let entry = relicario_core::OrgManifestEntry {
        id: item.id.clone(),
        r#type: item.r#type,
        title: item.title.clone(),
        tags: item.tags.clone(),
        modified: item.modified,
        trashed_at: item.trashed_at,
        collection: collection.to_string(),
    };
    if let Some(slot) = manifest.entries.iter_mut().find(|e| e.id == item.id) {
        *slot = entry;
    } else {
        manifest.entries.push(entry);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use relicario_core::{MemberId, OrgMembers, OrgRole, OrgMember};

    #[test]
    fn parse_trailers_extracts_relicario_fields() {
        // Contract trailer shape: "Relicario-Actor: <name> <member_id>".
        let raw = "Relicario-Actor: alice a1b2c3d4e5f6a1b2\nRelicario-Action: item-create\nRelicario-Collection: prod\n";
        let event = parse_trailer_block("abc123", "2026-06-06T12:00:00+00:00", raw);
        assert_eq!(event.action.as_deref(), Some("item-create"));
        assert_eq!(event.collection.as_deref(), Some("prod"));
        // The verified actor_id is resolved later from the signature, not the trailer;
        // the trailer only populates trailer_actor_id here.
        assert_eq!(event.trailer_actor_id.as_deref(), Some("a1b2c3d4e5f6a1b2"));
        assert_eq!(event.actor_id, None);
        assert!(!event.tampered);
    }

    fn alice() -> OrgMember {
        OrgMember {
            member_id: MemberId::new(),
            display_name: "Alice".into(),
            role: OrgRole::Member,
            ed25519_pubkey: "ssh-ed25519 AAAA fake".into(),
            collections: vec![],
            added_at: 0,
            added_by: MemberId::new(),
        }
    }

    #[test]
    fn new_key_differs_from_old_key() {
        let k1 = relicario_core::generate_org_key();
        let k2 = relicario_core::generate_org_key();
        assert_ne!(*k1, *k2);
    }

    #[test]
    fn set_role_changes_role() {
        let mut members = OrgMembers::new();
        let a = alice();
        let id = a.member_id.clone();
        members.members.push(a);
        if let Some(m) = members.find_by_id_mut(&id) {
            m.role = OrgRole::Admin;
        }
        assert_eq!(members.find_by_id(&id).unwrap().role, OrgRole::Admin);
    }

    #[test]
    fn grant_adds_slug_to_member_collections() {
        let mut members = OrgMembers::new();
        let a = alice();
        let id = a.member_id.clone();
        members.members.push(a);

        let m = members.find_by_id_mut(&id).unwrap();
        if !m.collections.contains(&"prod".to_string()) {
            m.collections.push("prod".to_string());
        }
        assert!(members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string()));
    }

    #[test]
    fn revoke_removes_slug_from_member_collections() {
        let mut members = OrgMembers::new();
        let mut a = alice();
        a.collections = vec!["prod".into(), "dev".into()];
        let id = a.member_id.clone();
        members.members.push(a);

        let m = members.find_by_id_mut(&id).unwrap();
        m.collections.retain(|s| s != "prod");
        assert!(!members.find_by_id(&id).unwrap().collections.contains(&"prod".to_string()));
        assert!(members.find_by_id(&id).unwrap().collections.contains(&"dev".to_string()));
    }
}
