//! Unlocked-vault session: the shape every vault-mutating command works with.
//!
//! Holds the derived master key in `Zeroizing<[u8; 32]>` for the lifetime of a
//! CLI invocation. Drops it (via Zeroize) when the struct goes out of scope.

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

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

use relicario_core::{
    decrypt_item, decrypt_manifest, decrypt_settings,
    derive_master_key, encrypt_item, encrypt_manifest, encrypt_settings,
    imgsecret, Item, ItemId, KdfParams, Manifest, SecondFactor, VaultSettings,
};

use crate::helpers::vault_dir;

/// A vault whose master key has been derived and is held in memory.
/// The key is wiped via `Zeroize` when this struct drops.
pub struct UnlockedVault {
    root: PathBuf,
    master_key: Zeroizing<[u8; 32]>,
}

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

    /// Full interactive unlock flow: locate vault, prompt passphrase, locate
    /// reference image, derive master key.
    pub fn unlock_interactive() -> Result<Self> {
        let root = vault_dir()?;
        let salt = read_salt(&root)?;
        let params = read_params(&root)?;
        let image_path = get_image_path()?;
        let image_bytes = fs::read(&image_path)
            .with_context(|| format!("failed to read reference image {}", image_path.display()))?;
        let image_secret = Zeroizing::new(imgsecret::extract(&image_bytes)?);

        let passphrase = if let Some(p) = crate::test_passphrase_override() {
            Zeroizing::new(p)
        } else {
            Zeroizing::new(
                rpassword::prompt_password("Passphrase: ")
                    .context("failed to read passphrase")?
            )
        };

        let master_key = derive_master_key(
            passphrase.as_bytes(),
            &image_secret,
            &salt,
            &params,
        )?;

        Ok(Self { root, master_key })
    }

    pub fn manifest_path(&self) -> PathBuf { self.root.join("manifest.enc") }
    pub fn settings_path(&self) -> PathBuf { self.root.join("settings.enc") }
    pub fn item_path(&self, id: &ItemId) -> PathBuf {
        self.root.join("items").join(format!("{}.enc", id.as_str()))
    }

    pub fn load_manifest(&self) -> Result<Manifest> {
        let bytes = fs::read(self.manifest_path()).context("failed to read manifest.enc")?;
        Ok(decrypt_manifest(&bytes, &self.master_key)?)
    }

    /// Save the manifest and refresh the plaintext groups.cache. This is the
    /// canonical "I just mutated the manifest" funnel — every command that
    /// changes the manifest goes through this method, so cache freshness is
    /// a compile-time invariant rather than a discipline rule.
    pub fn after_manifest_change(&self, manifest: &Manifest) -> Result<()> {
        let bytes = encrypt_manifest(manifest, &self.master_key)?;
        atomic_write(&self.manifest_path(), &bytes)?;
        crate::helpers::refresh_groups_cache(&self.root, manifest);
        Ok(())
    }

    pub fn load_settings(&self) -> Result<VaultSettings> {
        let bytes = fs::read(self.settings_path()).context("failed to read settings.enc")?;
        Ok(decrypt_settings(&bytes, &self.master_key)?)
    }

    pub fn save_settings(&self, settings: &VaultSettings) -> Result<()> {
        let bytes = encrypt_settings(settings, &self.master_key)?;
        atomic_write(&self.settings_path(), &bytes)
    }

    pub fn load_item(&self, id: &ItemId) -> Result<Item> {
        let bytes = fs::read(self.item_path(id))
            .with_context(|| format!("failed to read item {}", id.as_str()))?;
        Ok(decrypt_item(&bytes, &self.master_key)?)
    }

    pub fn save_item(&self, item: &Item) -> Result<()> {
        let path = self.item_path(&item.id);
        if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; }
        let bytes = encrypt_item(item, &self.master_key)?;
        atomic_write(&path, &bytes)
    }
}

fn read_salt(root: &Path) -> Result<[u8; 32]> {
    let data = fs::read(root.join(".relicario").join("salt"))
        .context("failed to read .relicario/salt")?;
    if data.len() != 32 { bail!("invalid salt length: {}", data.len()); }
    let mut salt = [0u8; 32];
    salt.copy_from_slice(&data);
    Ok(salt)
}

#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct ParamsFile {
    pub format_version: u32,
    pub kdf: ParamsKdf,
    pub aead: String,
    pub salt_path: String,
    /// Which second-factor container this vault uses to supply its 256-bit secret.
    /// Defaults to `Image` (reference JPEG via DCT stego) for back-compat with
    /// params.json files written before keyfile support was introduced.
    #[serde(default)]
    pub second_factor: SecondFactor,
}

#[derive(serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct ParamsKdf {
    pub algorithm: String,
    pub argon2_m: u32,
    pub argon2_t: u32,
    pub argon2_p: u32,
}

impl ParamsFile {
    pub fn for_new_vault(params: &KdfParams) -> Self {
        Self {
            format_version: 2,
            kdf: ParamsKdf {
                algorithm: "argon2id-v0x13".into(),
                argon2_m: params.argon2_m,
                argon2_t: params.argon2_t,
                argon2_p: params.argon2_p,
            },
            aead: "xchacha20poly1305".into(),
            salt_path: ".relicario/salt".into(),
            second_factor: SecondFactor::Image,
        }
    }

    pub fn to_kdf_params(&self) -> KdfParams {
        KdfParams {
            argon2_m: self.kdf.argon2_m,
            argon2_t: self.kdf.argon2_t,
            argon2_p: self.kdf.argon2_p,
        }
    }
}

fn read_params(root: &Path) -> Result<KdfParams> {
    let s = fs::read_to_string(root.join(".relicario").join("params.json"))
        .context("failed to read .relicario/params.json")?;
    let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?;
    Ok(pf.to_kdf_params())
}

/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt.
pub fn get_image_path() -> Result<PathBuf> {
    if let Ok(path) = std::env::var("RELICARIO_IMAGE") {
        return Ok(PathBuf::from(path));
    }
    // Also accept <vault_root>/reference.jpg as a convention.
    if let Ok(root) = vault_dir() {
        let default = root.join("reference.jpg");
        if default.exists() { return Ok(default); }
    }
    eprint!("Reference image path: ");
    std::io::Write::flush(&mut std::io::stderr())?;
    let mut line = String::new();
    std::io::stdin().read_line(&mut line)?;
    let trimmed = line.trim();
    if trimmed.is_empty() { bail!("no reference image path provided"); }
    Ok(PathBuf::from(trimmed))
}

/// Atomic write: write to <path>.tmp, then rename over <path>. Keeps the
/// vault file consistent if we crash mid-write.
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!("failed to write {}", tmp.display()))?;
    fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    const FIXTURE: &str = r#"{
  "format_version": 2,
  "kdf": {
    "algorithm": "argon2id-v0x13",
    "argon2_m": 65536,
    "argon2_t": 3,
    "argon2_p": 4
  },
  "aead": "xchacha20poly1305",
  "salt_path": ".relicario/salt"
}"#;

    #[test]
    fn params_file_round_trips_current_layout() {
        let pf: ParamsFile = serde_json::from_str(FIXTURE).expect("parse fixture");
        assert_eq!(pf.format_version, 2);
        assert_eq!(pf.kdf.algorithm, "argon2id-v0x13");
        assert_eq!(pf.kdf.argon2_m, 65536);
        assert_eq!(pf.kdf.argon2_t, 3);
        assert_eq!(pf.kdf.argon2_p, 4);
        assert_eq!(pf.aead, "xchacha20poly1305");
        assert_eq!(pf.salt_path, ".relicario/salt");

        let kdf = pf.to_kdf_params();
        assert_eq!(kdf.argon2_m, 65536);
        assert_eq!(kdf.argon2_t, 3);
        assert_eq!(kdf.argon2_p, 4);

        let serialized = serde_json::to_string(&pf).expect("re-serialize");
        let pf2: ParamsFile = serde_json::from_str(&serialized).expect("parse re-serialized");
        assert_eq!(pf2.format_version, 2);
        assert_eq!(pf2.kdf.algorithm, "argon2id-v0x13");
        assert_eq!(pf2.kdf.argon2_m, 65536);
        assert_eq!(pf2.kdf.argon2_t, 3);
        assert_eq!(pf2.kdf.argon2_p, 4);
        assert_eq!(pf2.aead, "xchacha20poly1305");
        assert_eq!(pf2.salt_path, ".relicario/salt");
    }

    #[test]
    fn for_new_vault_produces_expected_shape() {
        let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
        let pf = ParamsFile::for_new_vault(&params);
        let v = serde_json::to_value(&pf).expect("to_value");
        assert_eq!(v["format_version"], 2);
        assert_eq!(v["kdf"]["algorithm"], "argon2id-v0x13");
        assert_eq!(v["kdf"]["argon2_m"], 65536);
        assert_eq!(v["kdf"]["argon2_t"], 3);
        assert_eq!(v["kdf"]["argon2_p"], 4);
        assert_eq!(v["aead"], "xchacha20poly1305");
        assert_eq!(v["salt_path"], ".relicario/salt");
    }

    #[test]
    fn params_file_second_factor_backcompat() {
        // An old params.json without second_factor must deserialize with Image (back-compat).
        let pf: ParamsFile = serde_json::from_str(FIXTURE).expect("parse old fixture");
        assert_eq!(pf.second_factor, relicario_core::SecondFactor::Image);
    }

    #[test]
    fn params_file_second_factor_keyfile() {
        // A params.json WITH "second_factor":"keyfile" must deserialize as Keyfile.
        let with_keyfile = r#"{
  "format_version": 2,
  "kdf": {
    "algorithm": "argon2id-v0x13",
    "argon2_m": 65536,
    "argon2_t": 3,
    "argon2_p": 4
  },
  "aead": "xchacha20poly1305",
  "salt_path": ".relicario/salt",
  "second_factor": "keyfile"
}"#;
        let pf: ParamsFile = serde_json::from_str(with_keyfile).expect("parse keyfile fixture");
        assert_eq!(pf.second_factor, relicario_core::SecondFactor::Keyfile);
    }

    #[test]
    fn after_manifest_change_writes_manifest_and_groups_cache() {
        let dir = tempfile::TempDir::new().unwrap();
        let root = dir.path().to_path_buf();
        std::fs::create_dir_all(root.join(".relicario")).unwrap();
        std::fs::create_dir_all(root.join("items")).unwrap();
        let vault = UnlockedVault {
            root: root.clone(),
            master_key: Zeroizing::new([0u8; 32]),
        };
        let manifest = Manifest::new();

        vault.after_manifest_change(&manifest).unwrap();
        assert!(root.join("manifest.enc").exists());
        assert!(root.join(".relicario/groups.cache").exists());
    }
}
