//! Backup container — encrypted, compressed, single-file archive of a vault.
//!
//! ## Format (v1)
//!
//! ```text
//! [magic "RBAK" 4 bytes][version 0x01 1 byte][salt 32 bytes][nonce 24 bytes][ciphertext+tag]
//! ```
//!
//! After AEAD decryption, the plaintext is zstd-compressed bytes whose
//! decompressed form is a UTF-8 JSON document — see [`Envelope`].
//!
//! The backup container key is **independent** of any vault master key.
//! The user picks a backup passphrase at export and types it at restore.
//! Argon2id parameters are pinned to v1-of-this-format (m=64MiB, t=3, p=4)
//! so a v1 reader does not need to negotiate them.

use argon2::{Algorithm, Argon2, Params, Version};
use base64::Engine;
use chacha20poly1305::{
    aead::{Aead, KeyInit},
    XChaCha20Poly1305, XNonce,
};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;

use crate::error::{RelicarioError, Result};

/// File-level magic. Four bytes so a `file(1)` rule can identify it.
pub const MAGIC: [u8; 4] = *b"RBAK";

/// Container format version. Bumped if the on-disk layout of the
/// salt/nonce/ciphertext header or the AEAD primitive changes.
pub const FORMAT_VERSION: u8 = 0x01;

/// JSON envelope schema version. Bumped if the JSON shape changes
/// without an underlying-format change (e.g. new optional fields whose
/// absence v1 readers can tolerate would NOT bump this; renames or
/// removals would).
pub const SCHEMA_VERSION: u32 = 1;

const SALT_LEN: usize = 32;
const NONCE_LEN: usize = 24;
const TAG_LEN: usize = 16;
const HEADER_LEN: usize = 4 + 1 + SALT_LEN + NONCE_LEN; // magic + version + salt + nonce

const ARGON2_M_KIB: u32 = 65_536;     // 64 MiB
const ARGON2_T: u32 = 3;
const ARGON2_P: u32 = 4;

/// Zstd compression level. 3 is the speed/size sweet spot.
const ZSTD_LEVEL: i32 = 3;

/// Inputs to [`pack_backup`]. Borrow-only — the caller retains ownership of
/// every byte slice.
pub struct BackupInput<'a> {
    /// Raw 32-byte vault salt (`.relicario/salt` contents).
    pub salt: &'a [u8],
    /// Verbatim string contents of `.relicario/params.json`.
    pub params_json: &'a str,
    /// Verbatim string contents of `.relicario/devices.json`.
    pub devices_json: &'a str,
    /// Encrypted manifest bytes (verbatim `manifest.enc`).
    pub manifest_enc: &'a [u8],
    /// Encrypted vault settings bytes (verbatim `settings.enc`).
    pub settings_enc: &'a [u8],
    /// One entry per item file (verbatim ciphertext).
    pub items: Vec<BackupItem<'a>>,
    /// One entry per attachment blob (verbatim ciphertext).
    pub attachments: Vec<BackupAttachment<'a>>,
    /// Reference JPEG bytes — included iff caller wants to bundle the
    /// second factor.
    pub reference_jpg: Option<&'a [u8]>,
    /// Tarred `.git/` directory — included iff caller wants the audit log.
    /// The caller (CLI) does the actual tarring; core just transports the
    /// opaque bytes.
    pub git_archive: Option<&'a [u8]>,
}

/// One vault item ciphertext, keyed by the item id (16-char hex).
pub struct BackupItem<'a> {
    pub id: String,
    pub ciphertext: &'a [u8],
}

/// One attachment blob, keyed by `<item_id>/<attachment_id>` so the
/// per-item directory layout round-trips.
pub struct BackupAttachment<'a> {
    pub item_id: String,
    pub attachment_id: String,
    pub ciphertext: &'a [u8],
}

/// Output of [`unpack_backup`]. Owned bytes — the caller decides where to
/// persist them.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackupOutput {
    pub salt: [u8; 32],
    pub params_json: String,
    pub devices_json: String,
    pub manifest_enc: Vec<u8>,
    pub settings_enc: Vec<u8>,
    pub items: Vec<UnpackedItem>,
    pub attachments: Vec<UnpackedAttachment>,
    pub reference_jpg: Option<Vec<u8>>,
    pub git_archive: Option<Vec<u8>>,
    pub created_at: i64,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnpackedItem {
    pub id: String,
    pub ciphertext: Vec<u8>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnpackedAttachment {
    pub item_id: String,
    pub attachment_id: String,
    pub ciphertext: Vec<u8>,
}

#[derive(Serialize, Deserialize)]
struct Envelope {
    schema_version: u32,
    created_at: i64,
    vault: VaultEnvelope,
}

#[derive(Serialize, Deserialize)]
struct VaultEnvelope {
    /// base64-encoded 32-byte vault salt.
    salt: String,
    /// Verbatim params.json contents (string, not nested object — keeps
    /// forward-compat with future params.json schema changes opaque to
    /// the backup format).
    params: String,
    /// Verbatim devices.json contents (string for the same reason).
    devices: String,
    /// base64-encoded ciphertext of `manifest.enc`.
    manifest: String,
    /// base64-encoded ciphertext of `settings.enc`.
    settings: String,
    /// Map of `item_id` → base64-encoded item ciphertext.
    items: std::collections::BTreeMap<String, String>,
    /// Map of `<item_id>/<attachment_id>` → base64-encoded ciphertext.
    attachments: std::collections::BTreeMap<String, String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    reference_jpg: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    git_archive: Option<String>,
}

/// Pack a vault into the `.relbak` container.
///
/// Generates fresh 32-byte salt + 24-byte nonce via OsRng. Derives a
/// 32-byte key via Argon2id with the format-pinned parameters, then
/// XChaCha20-Poly1305 encrypts the zstd-compressed JSON envelope.
pub fn pack_backup(input: BackupInput<'_>, passphrase: &str) -> Result<Vec<u8>> {
    let mut salt = [0u8; SALT_LEN];
    OsRng.fill_bytes(&mut salt);
    let mut nonce_bytes = [0u8; NONCE_LEN];
    OsRng.fill_bytes(&mut nonce_bytes);

    let key = derive_backup_key(passphrase.as_bytes(), &salt)?;

    let envelope = build_envelope(input, crate::time::now_unix())?;
    let json = serde_json::to_vec(&envelope)?;

    let compressed = zstd::encode_all(&json[..], ZSTD_LEVEL)
        .map_err(|e| RelicarioError::Format(format!("zstd compress: {e}")))?;

    let cipher = XChaCha20Poly1305::new((&*key).into());
    let nonce = XNonce::from(nonce_bytes);
    let ciphertext = cipher
        .encrypt(&nonce, compressed.as_slice())
        .map_err(|e| RelicarioError::Encrypt(e.to_string()))?;

    let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len());
    out.extend_from_slice(&MAGIC);
    out.push(FORMAT_VERSION);
    out.extend_from_slice(&salt);
    out.extend_from_slice(&nonce_bytes);
    out.extend_from_slice(&ciphertext);
    Ok(out)
}

/// Unpack a `.relbak` container, verifying magic + version, decrypting,
/// decompressing, and parsing the JSON envelope.
pub fn unpack_backup(data: &[u8], passphrase: &str) -> Result<BackupOutput> {
    if data.len() < HEADER_LEN + TAG_LEN {
        return Err(RelicarioError::Format(
            "backup file truncated".into(),
        ));
    }
    if data[0..4] != MAGIC {
        return Err(RelicarioError::BackupBadMagic);
    }
    let version = data[4];
    if version != FORMAT_VERSION {
        return Err(RelicarioError::BackupUnsupportedVersion {
            found: version,
            expected: FORMAT_VERSION,
        });
    }

    let mut salt = [0u8; SALT_LEN];
    salt.copy_from_slice(&data[5..5 + SALT_LEN]);
    let nonce_start = 5 + SALT_LEN;
    let nonce_bytes: &[u8] = &data[nonce_start..nonce_start + NONCE_LEN];
    let ciphertext = &data[HEADER_LEN..];

    let key = derive_backup_key(passphrase.as_bytes(), &salt)?;

    let cipher = XChaCha20Poly1305::new((&*key).into());
    let nonce = XNonce::from_slice(nonce_bytes);
    let compressed = cipher
        .decrypt(nonce, ciphertext)
        .map_err(|_| RelicarioError::Decrypt)?;

    let json_bytes = zstd::decode_all(compressed.as_slice())
        .map_err(|e| RelicarioError::Format(format!("zstd decompress: {e}")))?;

    let env: Envelope = serde_json::from_slice(&json_bytes)?;
    if env.schema_version != SCHEMA_VERSION {
        return Err(RelicarioError::BackupSchemaMismatch {
            found: env.schema_version,
            expected: SCHEMA_VERSION,
        });
    }

    let b64 = base64::engine::general_purpose::STANDARD;
    let mut salt_out = [0u8; 32];
    let salt_decoded = b64
        .decode(&env.vault.salt)
        .map_err(|e| RelicarioError::Format(format!("base64 salt: {e}")))?;
    if salt_decoded.len() != 32 {
        return Err(RelicarioError::Format(format!(
            "salt length: expected 32, got {}",
            salt_decoded.len()
        )));
    }
    salt_out.copy_from_slice(&salt_decoded);

    let manifest_enc = b64
        .decode(&env.vault.manifest)
        .map_err(|e| RelicarioError::Format(format!("base64 manifest: {e}")))?;
    let settings_enc = b64
        .decode(&env.vault.settings)
        .map_err(|e| RelicarioError::Format(format!("base64 settings: {e}")))?;

    let mut items = Vec::with_capacity(env.vault.items.len());
    for (id, b64_ct) in env.vault.items {
        let ct = b64
            .decode(&b64_ct)
            .map_err(|e| RelicarioError::Format(format!("base64 item {id}: {e}")))?;
        items.push(UnpackedItem { id, ciphertext: ct });
    }

    let mut attachments = Vec::with_capacity(env.vault.attachments.len());
    for (combined, b64_ct) in env.vault.attachments {
        let (item_id, attachment_id) = combined
            .split_once('/')
            .map(|(a, b)| (a.to_string(), b.to_string()))
            .ok_or_else(|| {
                RelicarioError::Format(format!("bad attachment key '{combined}'"))
            })?;
        let ct = b64
            .decode(&b64_ct)
            .map_err(|e| RelicarioError::Format(format!("base64 attachment {combined}: {e}")))?;
        attachments.push(UnpackedAttachment { item_id, attachment_id, ciphertext: ct });
    }

    let reference_jpg = env
        .vault
        .reference_jpg
        .as_deref()
        .map(|s| b64.decode(s))
        .transpose()
        .map_err(|e| RelicarioError::Format(format!("base64 reference_jpg: {e}")))?;
    let git_archive = env
        .vault
        .git_archive
        .as_deref()
        .map(|s| b64.decode(s))
        .transpose()
        .map_err(|e| RelicarioError::Format(format!("base64 git_archive: {e}")))?;

    Ok(BackupOutput {
        salt: salt_out,
        params_json: env.vault.params,
        devices_json: env.vault.devices,
        manifest_enc,
        settings_enc,
        items,
        attachments,
        reference_jpg,
        git_archive,
        created_at: env.created_at,
    })
}

fn derive_backup_key(passphrase: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
    use unicode_normalization::UnicodeNormalization;

    // NFC normalize passphrase (matches derive_master_key in crypto.rs)
    let nfc_passphrase: Vec<u8> = match std::str::from_utf8(passphrase) {
        Ok(s) => s.nfc().collect::<String>().into_bytes(),
        Err(_) => passphrase.to_vec(),
    };

    let params = Params::new(ARGON2_M_KIB, ARGON2_T, ARGON2_P, Some(32))
        .map_err(|e| RelicarioError::Kdf(format!("argon2 params: {e}")))?;
    let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
    let mut key = Zeroizing::new([0u8; 32]);
    argon
        .hash_password_into(&nfc_passphrase, salt, key.as_mut_slice())
        .map_err(|e| RelicarioError::Kdf(format!("argon2 hash: {e}")))?;
    Ok(key)
}

fn build_envelope(input: BackupInput<'_>, created_at: i64) -> Result<Envelope> {
    let b64 = base64::engine::general_purpose::STANDARD;
    let mut items = std::collections::BTreeMap::new();
    for it in input.items {
        items.insert(it.id, b64.encode(it.ciphertext));
    }
    let mut attachments = std::collections::BTreeMap::new();
    for a in input.attachments {
        let key = format!("{}/{}", a.item_id, a.attachment_id);
        attachments.insert(key, b64.encode(a.ciphertext));
    }
    Ok(Envelope {
        schema_version: SCHEMA_VERSION,
        created_at,
        vault: VaultEnvelope {
            salt: b64.encode(input.salt),
            params: input.params_json.to_string(),
            devices: input.devices_json.to_string(),
            manifest: b64.encode(input.manifest_enc),
            settings: b64.encode(input.settings_enc),
            items,
            attachments,
            reference_jpg: input.reference_jpg.map(|b| b64.encode(b)),
            git_archive: input.git_archive.map(|b| b64.encode(b)),
        },
    })
}
