//! Unlocked org vault session: holds the org master key for the duration of a
//! CLI invocation.

use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use zeroize::Zeroizing;

use relicario_core::{
    decrypt_item, decrypt_org_manifest, encrypt_item, encrypt_org_manifest,
    AttachmentId, EncryptedAttachment, Item, ItemId, MemberId, OrgCollections, OrgManifest,
    OrgMember, OrgMembers, OrgMeta,
};

/// Default per-attachment cap for org vaults. Org vaults have no settings.enc,
/// so this mirrors the personal-vault default
/// `AttachmentCaps::per_attachment_max_bytes` at
/// crates/relicario-core/src/settings.rs:116.
pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 10 * 1024 * 1024;

pub struct UnlockedOrgVault {
    pub root: PathBuf,
    pub org_key: Zeroizing<[u8; 32]>,
}

impl UnlockedOrgVault {
    pub fn root(&self) -> &Path { &self.root }
    pub fn key(&self) -> &Zeroizing<[u8; 32]> { &self.org_key }

    pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") }

    /// Collection-scoped item path: `items/<collection-slug>/<id>.enc`.
    /// The leading slug segment is what the pre-receive hook authorizes against
    /// members.json — it never decrypts the blob. The slug must be non-empty and
    /// already validated.
    pub fn item_path(&self, collection_slug: &str, id: &ItemId) -> PathBuf {
        self.root
            .join("items")
            .join(collection_slug)
            .join(format!("{}.enc", id.as_str()))
    }

    pub fn member_key_path(&self, id: &MemberId) -> PathBuf {
        self.root.join("keys").join(format!("{}.enc", id.as_str()))
    }
    pub fn members_path(&self) -> PathBuf { self.root.join("members.json") }
    pub fn collections_path(&self) -> PathBuf { self.root.join("collections.json") }
    // OrgMeta accessors — part of the UnlockedOrgVault path/loader API surface
    // (parallel to members_path/collections_path + load_members), retained for
    // completeness. No command consumes org.json yet; surfacing the org
    // name/id in `org status` is a tracked follow-up, so allow until then.
    #[allow(dead_code)]
    pub fn org_meta_path(&self) -> PathBuf { self.root.join("org.json") }

    #[allow(dead_code)]
    pub fn load_meta(&self) -> Result<OrgMeta> {
        let s = fs::read_to_string(self.org_meta_path()).context("read org.json")?;
        serde_json::from_str(&s).context("parse org.json")
    }

    pub fn load_members(&self) -> Result<OrgMembers> {
        let s = fs::read_to_string(self.members_path()).context("read members.json")?;
        serde_json::from_str(&s).context("parse members.json")
    }

    pub fn save_members(&self, members: &OrgMembers) -> Result<()> {
        let json = serde_json::to_string_pretty(members)?;
        atomic_write(&self.members_path(), json.as_bytes())
    }

    pub fn load_collections(&self) -> Result<OrgCollections> {
        let s = fs::read_to_string(self.collections_path()).context("read collections.json")?;
        serde_json::from_str(&s).context("parse collections.json")
    }

    pub fn save_collections(&self, collections: &OrgCollections) -> Result<()> {
        let json = serde_json::to_string_pretty(collections)?;
        atomic_write(&self.collections_path(), json.as_bytes())
    }

    pub fn load_manifest(&self) -> Result<OrgManifest> {
        let bytes = fs::read(self.manifest_path()).context("read manifest.enc")?;
        Ok(decrypt_org_manifest(&bytes, &self.org_key)?)
    }

    pub fn save_manifest(&self, manifest: &OrgManifest) -> Result<()> {
        let bytes = encrypt_org_manifest(manifest, &self.org_key)?;
        atomic_write(&self.manifest_path(), &bytes)
    }

    /// Encrypt + write an item under its collection directory, creating the
    /// directory if needed. Returns the repo-relative path for git staging.
    pub fn save_item(&self, collection_slug: &str, item: &Item) -> Result<String> {
        let path = self.item_path(collection_slug, &item.id);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("create {}", parent.display()))?;
        }
        let bytes = encrypt_item(item, &self.org_key)?;
        atomic_write(&path, &bytes)?;
        Ok(format!("items/{}/{}.enc", collection_slug, item.id.as_str()))
    }

    /// Read + decrypt an item from its collection directory.
    pub fn load_item(&self, collection_slug: &str, id: &ItemId) -> Result<Item> {
        let path = self.item_path(collection_slug, id);
        let bytes = fs::read(&path)
            .with_context(|| format!("read item {}", path.display()))?;
        Ok(decrypt_item(&bytes, &self.org_key)?)
    }

    /// Delete an item blob. Missing file is not an error (partial-write
    /// recovery, same as the personal-vault purge path).
    pub fn remove_item(&self, collection_slug: &str, id: &ItemId) -> Result<()> {
        let path = self.item_path(collection_slug, id);
        match fs::remove_file(&path) {
            Ok(()) => Ok(()),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(e) => Err(anyhow::Error::from(e)
                .context(format!("delete {}", path.display()))),
        }
    }

    pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf {
        self.root.join("attachments").join(collection_slug)
            .join(item_id.as_str()).join(format!("{}.enc", att_id.as_str()))
    }

    /// Encrypt-already-done blob: persist it and return the repo-relative path for git staging.
    pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result<String> {
        let path = self.attachment_path(collection_slug, item_id, &enc.id);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
        }
        atomic_write(&path, &enc.bytes)?;
        Ok(format!("attachments/{}/{}/{}.enc", collection_slug, item_id.as_str(), enc.id.as_str()))
    }

    // Retained for a future `org document read/extract` command (mirrors `org_meta_path` convention).
    #[allow(dead_code)]
    pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result<Zeroizing<Vec<u8>>> {
        let path = self.attachment_path(collection_slug, item_id, att_id);
        let bytes = fs::read(&path).with_context(|| format!("read attachment {}", path.display()))?;
        Ok(relicario_core::decrypt_attachment(&bytes, &self.org_key)?)
    }

    /// Remove an item's whole attachment directory. Missing dir is NOT an error
    /// (mirrors `remove_item`'s NotFound-tolerant behavior, for partial-write recovery).
    pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()> {
        let dir = self.root.join("attachments").join(collection_slug).join(item_id.as_str());
        match fs::remove_dir_all(&dir) {
            Ok(()) => Ok(()),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
            Err(e) => Err(anyhow::Error::from(e).context(format!("remove {}", dir.display()))),
        }
    }

    /// Bail unless `member` has `slug` in their collection grants. The slug
    /// existence check is done separately by the caller against collections.json.
    pub fn ensure_grant(member: &OrgMember, slug: &str) -> Result<()> {
        if member.collections.iter().any(|c| c == slug) {
            Ok(())
        } else {
            bail!(
                "access denied: you do not have a grant for collection `{slug}` — ask an admin to run `relicario org grant`"
            )
        }
    }

    /// Load members.json and find the caller's member entry by matching the
    /// current device's ed25519 fingerprint against each member's pubkey
    /// fingerprint. Fingerprint comparison (not raw OpenSSH-string equality)
    /// tolerates comment/whitespace differences in the serialized key.
    pub fn current_member(&self) -> Result<relicario_core::OrgMember> {
        let device_fp = current_device_fingerprint()?;
        let members = self.load_members()?;
        members
            .members
            .into_iter()
            .find(|m| {
                relicario_core::fingerprint(&m.ed25519_pubkey)
                    .ok()
                    .as_deref()
                    == Some(device_fp.as_str())
            })
            .ok_or_else(|| {
                anyhow::anyhow!(
                    "your device key is not registered in this org — ask an admin to run `org add-member`"
                )
            })
    }
}

/// Locate the org vault root from RELICARIO_ORG_DIR env var or --dir flag value.
pub fn org_dir(dir_flag: Option<&std::path::Path>) -> Result<PathBuf> {
    if let Some(d) = dir_flag {
        return Ok(d.to_path_buf());
    }
    if let Ok(v) = std::env::var("RELICARIO_ORG_DIR") {
        return Ok(PathBuf::from(v));
    }
    bail!("org vault location required: set RELICARIO_ORG_DIR or pass --dir <path>")
}

/// Open an org vault: locate the root, read members.json to find the caller's
/// member entry (by ed25519 fingerprint), then unwrap their keys/<id>.enc to
/// recover the org master key.
pub fn open_org_vault(dir_flag: Option<&std::path::Path>) -> Result<UnlockedOrgVault> {
    let root = org_dir(dir_flag)?;

    let device_fp = current_device_fingerprint()?;
    let members_json = fs::read_to_string(root.join("members.json"))
        .context("read members.json — is this an org vault?")?;
    let members: OrgMembers = serde_json::from_str(&members_json).context("parse members.json")?;
    let member = members
        .members
        .iter()
        .find(|m| {
            relicario_core::fingerprint(&m.ed25519_pubkey)
                .ok()
                .as_deref()
                == Some(device_fp.as_str())
        })
        .ok_or_else(|| anyhow::anyhow!("your device key is not in this org"))?;

    // Load this member's wrapped key blob.
    let key_path = root
        .join("keys")
        .join(format!("{}.enc", member.member_id.as_str()));
    let wrapped =
        fs::read(&key_path).with_context(|| format!("read {}", key_path.display()))?;

    // Recover the device ed25519 seed and unwrap.
    let seed = crate::device::current_device_seed()?;
    let org_key = relicario_core::unwrap_org_key(&wrapped, &seed)?;

    Ok(UnlockedOrgVault { root, org_key })
}

/// OpenSSH SHA-256 fingerprint of the active device's signing key.
fn current_device_fingerprint() -> Result<String> {
    let name = crate::device::current_device()?
        .ok_or_else(|| anyhow::anyhow!("no active device — run `relicario device add` first"))?;
    let pub_path = crate::device::device_dir(&name)?.join("signing.pub");
    let pubkey = fs::read_to_string(&pub_path)
        .with_context(|| format!("read {}", pub_path.display()))?;
    Ok(relicario_core::fingerprint(pubkey.trim())?)
}

/// Recover the active device's ed25519 seed (the 32-byte private scalar source)
pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
    let mut tmp = path.as_os_str().to_owned();
    tmp.push(".tmp");
    let tmp = PathBuf::from(tmp);
    fs::write(&tmp, data).with_context(|| format!("write {}", tmp.display()))?;
    fs::rename(&tmp, path).with_context(|| format!("rename {}", tmp.display()))?;
    Ok(())
}

/// Run `git <args>` in the org repo, capturing output and replaying it on
/// failure. Unlike `crate::helpers::git_run`, this does NOT inject
/// `commit.gpgsign=false` / `core.hooksPath=/dev/null`: org commits MUST be
/// signed (the pre-receive hook verifies every commit's signature), and the
/// repo's signing config is established by `configure_git_signing` during
/// `org init`.
pub(crate) fn org_git_run(root: &Path, args: &[&str], context: &str) -> Result<()> {
    let output = std::process::Command::new("git")
        .current_dir(root)
        .args(args)
        .output()
        .with_context(|| format!("{context}: failed to spawn git"))?;
    if !output.status.success() {
        if !output.stdout.is_empty() {
            eprint!("{}", String::from_utf8_lossy(&output.stdout));
        }
        if !output.stderr.is_empty() {
            eprint!("{}", String::from_utf8_lossy(&output.stderr));
        }
        anyhow::bail!("{context}: git failed ({})", output.status);
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;
    use std::fs;

    fn make_vault(key: Zeroizing<[u8; 32]>) -> (TempDir, UnlockedOrgVault) {
        let dir = TempDir::new().unwrap();
        let root = dir.path().to_path_buf();
        fs::create_dir_all(root.join("items")).unwrap();
        fs::create_dir_all(root.join("keys")).unwrap();
        let vault = UnlockedOrgVault { root, org_key: key };
        (dir, vault)
    }

    #[test]
    fn unlocked_org_vault_paths() {
        let key = Zeroizing::new([0u8; 32]);
        let (dir, vault) = make_vault(key);
        let root = dir.path().to_path_buf();
        assert_eq!(vault.manifest_path(), root.join("manifest.enc"));
        assert_eq!(
            vault.member_key_path(&MemberId("abc0def1abc0def1".into())),
            root.join("keys/abc0def1abc0def1.enc")
        );
        assert_eq!(
            vault.item_path("prod", &relicario_core::ItemId("0123456789abcdef".into())),
            root.join("items/prod/0123456789abcdef.enc")
        );
    }

    #[test]
    fn save_and_load_manifest() {
        let key = Zeroizing::new([0xAAu8; 32]);
        let (dir, vault) = make_vault(key);
        let _ = dir; // keep alive
        let mut m = OrgManifest::new();
        m.entries.push(relicario_core::OrgManifestEntry {
            id: relicario_core::ItemId::new(),
            r#type: relicario_core::ItemType::SecureNote,
            title: "test".into(),
            tags: vec![],
            modified: 0,
            trashed_at: None,
            collection: "prod".into(),
        });
        vault.save_manifest(&m).unwrap();
        let loaded = vault.load_manifest().unwrap();
        assert_eq!(loaded.entries.len(), 1);
    }

    #[test]
    fn attachment_round_trip_collection_scoped() {
        use relicario_core::encrypt_attachment;
        let key = Zeroizing::new([7u8; 32]);
        let (dir, vault) = make_vault(key);
        let _ = dir; // keep tempdir alive
        let item_id = ItemId::new();
        let enc = encrypt_attachment(b"hello world", &vault.org_key, DEFAULT_ORG_ATTACHMENT_MAX_BYTES).unwrap();
        let rel = vault.save_attachment("eng", &item_id, &enc).unwrap();
        assert_eq!(rel, format!("attachments/eng/{}/{}.enc", item_id.as_str(), enc.id.as_str()));
        let got = vault.load_attachment("eng", &item_id, &enc.id).unwrap();
        assert_eq!(got.as_slice(), b"hello world");
        vault.remove_item_attachments("eng", &item_id).unwrap();
        assert!(vault.load_attachment("eng", &item_id, &enc.id).is_err());
    }

    #[test]
    fn save_and_load_members() {
        let key = Zeroizing::new([0u8; 32]);
        let (dir, vault) = make_vault(key);
        let _ = dir;
        let members = OrgMembers::new();
        vault.save_members(&members).unwrap();
        let loaded = vault.load_members().unwrap();
        assert_eq!(loaded.schema_version, 1);
    }
}
