//! Relicario CLI — the platform layer for the Relicario password manager.
//!
//! See module docs for the unlock flow and vault layout.

mod device;
mod gitea;
mod helpers;
mod session;

use std::path::PathBuf;

use anyhow::{bail, Context, Result};
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};

#[derive(Parser)]
#[command(
    name = "relicario",
    version,
    about = "Relicario — git-backed password manager with reference-image two-factor unlock"
)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Initialize a new vault in the current directory.
    Init {
        /// Carrier JPEG to embed the secret into.
        #[arg(long)]
        image: PathBuf,
        /// Output path for the reference image (gitignored).
        #[arg(long, default_value = "reference.jpg")]
        output: PathBuf,
    },

    /// Add a new item. Type-specific flags populate the core; missing fields
    /// are prompted for interactively.
    Add {
        #[command(subcommand)]
        kind: AddKind,
    },

    /// Print an item. Secrets are masked by default; pass --show to reveal.
    Get {
        /// Item id or case-insensitive title substring.
        query: String,
        /// Print secret field values in plaintext.
        #[arg(long)]
        show: bool,
        /// Copy the primary secret (Login.password, Card.number, etc.) to clipboard.
        #[arg(long)]
        copy: bool,
    },

    /// List items.
    List {
        #[arg(long)]
        r#type: Option<String>,
        #[arg(long)]
        group: Option<String>,
        #[arg(long)]
        tag: Option<String>,
        #[arg(long)]
        trashed: bool,
    },

    /// Edit an item interactively.
    Edit {
        query: String,
        /// Decode an `otpauth://` QR image to set the TOTP secret (login items only).
        #[arg(long, value_name = "PATH")]
        totp_qr: Option<PathBuf>,
    },

    /// View captured field history for an item. Values are masked by
    /// default; pass `--show` to reveal them.
    History {
        query: String,
        #[arg(long)]
        show: bool,
        /// Filter to a single field (matches against the synthetic key,
        /// e.g. `login_password`, `card_number`, `totp_secret`).
        #[arg(long)]
        field: Option<String>,
    },

    /// Soft-delete an item (moves to trash; reversible via `restore`).
    Rm { query: String },

    /// Restore a soft-deleted item.
    Restore { query: String },

    /// Permanently purge an item (and its attachments).
    Purge { query: String },

    /// Trash operations.
    Trash {
        #[command(subcommand)]
        action: TrashAction,
    },

    /// Backup operations: pack and unpack `.relbak` archives.
    Backup {
        #[command(subcommand)]
        action: BackupAction,
    },

    /// Import items from another password manager into the unlocked vault.
    Import {
        #[command(subcommand)]
        action: ImportAction,
    },

    /// Attach a file to an item.
    Attach { query: String, file: PathBuf },

    /// List attachments on an item.
    Attachments { query: String },

    /// Extract an attachment to disk.
    Extract {
        query: String,
        aid: String,
        #[arg(long)]
        out: Option<PathBuf>,
    },

    /// Remove an individual attachment from an item (deletes the encrypted
    /// blob and updates the item + manifest). Use `purge` to drop the entire
    /// item and all its attachments at once.
    Detach { query: String, aid: String },

    /// Generate a password or passphrase. When run inside an initialized
    /// vault, falls back to `settings generator-defaults` for unspecified
    /// flags; outside a vault, uses built-in defaults (length 20, safe
    /// symbol set, 5 BIP39 words, space separator).
    Generate {
        #[arg(long)]
        length: Option<u32>,
        #[arg(long)]
        bip39: bool,
        #[arg(long)]
        words: Option<u32>,
        #[arg(long)]
        symbols: Option<String>,
        /// Separator for BIP39 words.
        #[arg(long)]
        separator: Option<String>,
    },

    /// View or change vault settings.
    Settings {
        #[command(subcommand)]
        action: SettingsAction,
    },

    /// Sync with the git remote (pull --rebase + push).
    Sync,

    /// Print a summary of the vault: items, attachments, last commit.
    Status,

    /// Lock the vault (no-op in CLI; present for UX parity with the extension).
    Lock,

    /// Emit a shell completion script for the given shell.
    ///
    /// For `--group <TAB>` autocomplete, the bash/zsh/fish scripts read
    /// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
    /// which the CLI refreshes on every manifest read. Set
    /// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
    /// will fall back to no value enumeration).
    ///
    /// Pipe stdout to your shell's completion location (e.g.
    /// `relicario completions bash > /etc/bash_completion.d/relicario`).
    Completions {
        #[arg(value_enum)]
        shell: Shell,
    },

    /// Rate a passphrase with zxcvbn — prints score (0-4) and estimated
    /// guesses. Informational only; does not gate vault operations.
    ///
    /// Pass `-` as the argument to read one line from stdin instead, which
    /// keeps the passphrase out of shell history.
    Rate {
        /// Passphrase to score, or `-` to read from stdin.
        passphrase: String,
    },

    /// Manage registered devices (signing keys + deploy keys).
    Device {
        #[command(subcommand)]
        action: DeviceAction,
    },
}

#[derive(Subcommand)]
enum AddKind {
    Login {
        #[arg(long)] title: Option<String>,
        #[arg(long)] username: Option<String>,
        #[arg(long)] url: Option<String>,
        /// Prompt for password (vs reading from stdin or --password).
        #[arg(long)] password_prompt: bool,
        #[arg(long)] password: Option<String>,
        #[arg(long)] group: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
        #[arg(long)] favorite: bool,
        /// Decode an `otpauth://` QR image to fill the TOTP secret.
        #[arg(long, value_name = "PATH")] totp_qr: Option<PathBuf>,
    },
    SecureNote {
        #[arg(long)] title: Option<String>,
        #[arg(long)] body_prompt: bool,
        #[arg(long)] group: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },
    Identity {
        #[arg(long)] title: Option<String>,
        #[arg(long)] full_name: Option<String>,
        #[arg(long)] email: Option<String>,
        #[arg(long)] phone: Option<String>,
        #[arg(long)] date_of_birth: Option<String>,
        #[arg(long)] group: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },
    Card {
        #[arg(long)] title: Option<String>,
        #[arg(long)] holder: Option<String>,
        #[arg(long)] expiry: Option<String>, // MM/YYYY
        #[arg(long, default_value = "credit")] kind: String,
        #[arg(long)] group: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },
    Key {
        #[arg(long)] title: Option<String>,
        #[arg(long)] label: Option<String>,
        #[arg(long)] algorithm: Option<String>,
        #[arg(long)] group: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },
    Document {
        #[arg(long)] title: Option<String>,
        #[arg(long)] file: PathBuf,
        #[arg(long)] group: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },
    Totp {
        #[arg(long)] title: Option<String>,
        #[arg(long)] issuer: Option<String>,
        #[arg(long)] label: Option<String>,
        #[arg(long)] secret: Option<String>, // base32
        #[arg(long, default_value = "30")] period: u32,
        #[arg(long, default_value = "6")] digits: u8,
        #[arg(long, default_value = "sha1")] algorithm: String,
        #[arg(long)] group: Option<String>,
        #[arg(long, value_delimiter = ',')] tags: Vec<String>,
    },
}

#[derive(Subcommand)]
enum TrashAction {
    /// List trashed items.
    List,
    /// Purge every trashed item past its retention window.
    Empty,
}

#[derive(Subcommand)]
enum SettingsAction {
    /// Show current settings as JSON.
    Show,
    /// Set trash retention (e.g., --days 30 or --forever).
    TrashRetention {
        #[arg(long)] days: Option<u32>,
        #[arg(long)] forever: bool,
    },
    /// Set field history retention.
    HistoryRetention {
        #[arg(long)] last_n: Option<u32>,
        #[arg(long)] days: Option<u32>,
        #[arg(long)] forever: bool,
    },
    /// Set per-attachment max size in bytes.
    AttachmentCap {
        #[arg(long)] per_attachment_max_bytes: Option<u64>,
        #[arg(long)] per_item_max_count: Option<u32>,
        #[arg(long)] per_vault_soft_cap_bytes: Option<u64>,
        #[arg(long)] per_vault_hard_cap_bytes: Option<u64>,
    },
    /// Update the default password / passphrase generator settings used by
    /// `relicario generate` when run inside this vault. Pass `--bip39` or
    /// `--random` to switch mode; per-attribute flags update fields of the
    /// chosen mode.
    GeneratorDefaults {
        /// Switch the default mode to random-character password.
        #[arg(long, conflicts_with = "bip39")]
        random: bool,
        /// Switch the default mode to BIP39 passphrase.
        #[arg(long, conflicts_with = "random")]
        bip39: bool,
        /// Random mode: total password length.
        #[arg(long)] length: Option<u32>,
        /// BIP39 mode: number of words.
        #[arg(long)] words: Option<u32>,
        /// Random mode: symbol charset (`safe`, `extended`, or a custom literal).
        #[arg(long)] symbols: Option<String>,
        /// BIP39 mode: word separator.
        #[arg(long)] separator: Option<String>,
    },
}

#[derive(Subcommand)]
enum BackupAction {
    /// Pack the local vault into a single encrypted `.relbak` file.
    /// Backup passphrase is independent of the vault passphrase.
    Export {
        /// Output `.relbak` path.
        out: PathBuf,
        /// Bundle the reference JPEG into the encrypted envelope.
        #[arg(long)]
        include_image: bool,
        /// Override the reference image path (defaults to the vault's
        /// `reference.jpg` or `RELICARIO_IMAGE`).
        #[arg(long)]
        image: Option<PathBuf>,
        /// Skip bundling `.git/` history.
        #[arg(long)]
        no_history: bool,
    },
    /// Unpack a `.relbak` file into a fresh vault directory.
    Restore {
        /// Input `.relbak` path.
        input: PathBuf,
        /// Target directory (must NOT already contain `.relicario/`).
        /// Defaults to the current directory.
        #[arg(default_value = ".")]
        target: PathBuf,
    },
}

#[derive(Subcommand)]
enum ImportAction {
    /// Import a LastPass CSV export into the unlocked vault.
    /// Each row creates a new item with a freshly-minted ID; title
    /// collisions are kept (no dedup). Failed rows are skipped and
    /// reported on stderr.
    Lastpass {
        /// Path to the LastPass-format CSV export.
        csv: PathBuf,
    },
}

#[derive(Subcommand)]
enum DeviceAction {
    /// Register this machine as a new device.
    ///
    /// Generates two ed25519 keypairs: one for signing commits, one for push
    /// access (deploy key). The deploy public key is registered via the Gitea
    /// API. Both private keys are stored locally in
    /// `~/.config/relicario/devices/<name>/`. The vault's `.relicario/devices.json`
    /// is updated and committed.
    ///
    /// Required environment variables (or flags):
    ///   RELICARIO_GITEA_URL    — e.g. https://git.example.com
    ///   RELICARIO_GITEA_TOKEN  — personal access token with repo write access
    ///   RELICARIO_GITEA_OWNER  — repository owner
    ///   RELICARIO_GITEA_REPO   — repository name
    Add {
        /// Human-readable name for this device (e.g. "laptop-2026").
        #[arg(long)]
        name: String,
        /// Gitea API base URL (overrides RELICARIO_GITEA_URL).
        #[arg(long)]
        gitea_url: Option<String>,
        /// Gitea personal access token (overrides RELICARIO_GITEA_TOKEN).
        #[arg(long)]
        gitea_token: Option<String>,
        /// Gitea repository owner (overrides RELICARIO_GITEA_OWNER).
        #[arg(long)]
        owner: Option<String>,
        /// Gitea repository name (overrides RELICARIO_GITEA_REPO).
        #[arg(long)]
        repo: Option<String>,
        /// Skip Gitea API registration (useful when the remote is not Gitea).
        #[arg(long)]
        no_gitea: bool,
    },
    /// Revoke a registered device.
    ///
    /// Removes the device from `devices.json`, adds it to `revoked.json`,
    /// deletes the deploy key from Gitea, and commits the change.
    Revoke {
        /// Name of the device to revoke.
        #[arg(long)]
        name: String,
    },
    /// List registered devices.
    List,
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    match cli.command {
        Commands::Init { image, output } => cmd_init(image, output),
        Commands::Add { kind } => cmd_add(kind),
        Commands::Get { query, show, copy } => cmd_get(query, show, copy),
        Commands::List { r#type, group, tag, trashed } => cmd_list(r#type, group, tag, trashed),
        Commands::Edit { query, totp_qr } => cmd_edit(query, totp_qr),
        Commands::History { query, show, field } => cmd_history(query, show, field),
        Commands::Rm { query } => cmd_rm(query),
        Commands::Restore { query } => cmd_restore(query),
        Commands::Purge { query } => cmd_purge(query),
        Commands::Trash { action } => cmd_trash(action),
        Commands::Backup { action } => cmd_backup(action),
        Commands::Import { action } => cmd_import(action),
        Commands::Attach { query, file } => cmd_attach(query, file),
        Commands::Attachments { query } => cmd_attachments(query),
        Commands::Extract { query, aid, out } => cmd_extract(query, aid, out),
        Commands::Detach { query, aid } => cmd_detach(query, aid),
        Commands::Generate { length, bip39, words, symbols, separator } => {
            cmd_generate(length, bip39, words, symbols, separator)
        }
        Commands::Settings { action } => cmd_settings(action),
        Commands::Sync => cmd_sync(),
        Commands::Status => cmd_status(),
        Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
        Commands::Completions { shell } => {
            let mut cmd = Cli::command();
            generate(shell, &mut cmd, "relicario", &mut std::io::stdout());
            Ok(())
        }
        Commands::Rate { passphrase } => cmd_rate(passphrase),
        Commands::Device { action } => cmd_device(action),
    }
}

/// Collect all non-empty group names from the manifest and write them to the
/// plaintext `groups.cache` file so shell completion can enumerate `--group`
/// candidates without prompting for the vault passphrase.
///
/// Failures are silently swallowed — a missing cache is merely a UX degradation,
/// not a correctness problem.
fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core::Manifest) {
    let mut set = std::collections::BTreeSet::<String>::new();
    for entry in manifest.items.values() {
        if let Some(g) = entry.group.as_ref() {
            if !g.is_empty() {
                set.insert(g.clone());
            }
        }
    }
    let _ = helpers::write_groups_cache(vault_dir, &set);
}

/// Check for test passphrase override (debug builds only; stripped from release).
#[cfg(debug_assertions)]
pub(crate) fn test_passphrase_override() -> Option<String> {
    std::env::var("RELICARIO_TEST_PASSPHRASE").ok()
}
#[cfg(not(debug_assertions))]
pub(crate) fn test_passphrase_override() -> Option<String> {
    None
}

/// Check for test item secret override (debug builds only; stripped from release).
#[cfg(debug_assertions)]
fn test_item_secret_override() -> Option<String> {
    std::env::var("RELICARIO_TEST_ITEM_SECRET").ok()
}
#[cfg(not(debug_assertions))]
fn test_item_secret_override() -> Option<String> {
    None
}

/// Check for test backup passphrase override (debug builds only; stripped from release).
#[cfg(debug_assertions)]
fn test_backup_passphrase_override() -> Option<String> {
    std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE").ok()
}
#[cfg(not(debug_assertions))]
fn test_backup_passphrase_override() -> Option<String> {
    None
}

/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
/// for integration-test use (rpassword reads /dev/tty by default, which is
/// unavailable in assert_cmd-spawned children).
fn prompt_secret(label: &str) -> Result<String> {
    if let Some(s) = test_item_secret_override() {
        return Ok(s);
    }
    rpassword::prompt_password(label).map_err(Into::into)
}

fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
    use std::fs;
    use rand::{rngs::OsRng, RngCore};
    use relicario_core::{
        derive_master_key, encrypt_manifest, encrypt_settings, imgsecret,
        validate_passphrase_strength, KdfParams, Manifest, VaultSettings,
    };
    use zeroize::Zeroizing;

    let root = std::env::current_dir()?;
    let relicario_dir = root.join(".relicario");
    if relicario_dir.exists() {
        anyhow::bail!(".relicario/ already exists in {}", root.display());
    }

    // Passphrase with strength gate (audit H3).
    // RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the
    // TTY prompt so integration tests can run without a real TTY.
    let passphrase = if let Some(p) = test_passphrase_override() {
        Zeroizing::new(p)
    } else {
        Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?)
    };
    let confirm = if test_passphrase_override().is_some() {
        passphrase.clone()
    } else {
        Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
    };
    if passphrase.as_str() != confirm.as_str() {
        anyhow::bail!("passphrases do not match");
    }
    if let Err(e) = validate_passphrase_strength(&passphrase) {
        anyhow::bail!("{}. Choose a longer or more entropic phrase.", e);
    }

    // Image secret: 32 random bytes, embedded in the carrier.
    let image_secret = {
        let mut buf = Zeroizing::new([0u8; 32]);
        OsRng.fill_bytes(buf.as_mut_slice());
        buf
    };
    let carrier = fs::read(&image)
        .with_context(|| format!("failed to read carrier image {}", image.display()))?;
    let stego = imgsecret::embed(&carrier, &*image_secret)?;
    fs::write(&output, &stego)
        .with_context(|| format!("failed to write reference image {}", output.display()))?;

    // Vault salt + KDF params.
    let mut salt = [0u8; 32];
    OsRng.fill_bytes(&mut salt);
    let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };

    // Derive master key, then persist an empty Manifest + default VaultSettings.
    let master_key = derive_master_key(passphrase.as_bytes(), &*image_secret, &salt, &params)?;

    fs::create_dir_all(&relicario_dir)?;
    fs::create_dir_all(root.join("items"))?;
    fs::create_dir_all(root.join("attachments"))?;
    fs::write(relicario_dir.join("salt"), salt)?;
    fs::write(
        relicario_dir.join("params.json"),
        serde_json::to_string_pretty(&ParamsFile {
            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(),
        })?,
    )?;
    let manifest = Manifest::new();
    fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
    let settings = VaultSettings::default();
    fs::write(root.join("settings.enc"), encrypt_settings(&settings, &master_key)?)?;

    // .gitignore excludes the reference image.
    let fname = output.file_name()
        .ok_or_else(|| anyhow::anyhow!("output path has no filename: {}", output.display()))?
        .to_string_lossy();
    let gitignore = format!("{fname}\n");
    fs::write(root.join(".gitignore"), gitignore)?;

    // git init + initial commit via hardened wrapper.
    let status = crate::helpers::git_command(&root, &["init"]).status()?;
    if !status.success() { anyhow::bail!("git init failed"); }
    let _ = crate::helpers::git_command(&root, &[
        "add", ".gitignore", ".relicario/params.json",
        ".relicario/salt", "manifest.enc", "settings.enc",
    ]).status()?;
    let status = crate::helpers::git_command(&root, &[
        "commit", "-m", "init: new Relicario vault (format v2)",
    ]).status()?;
    if !status.success() { anyhow::bail!("git commit failed"); }

    eprintln!("Vault initialized at {}", root.display());
    eprintln!("Reference image: {}", output.display());
    eprintln!("  \u{2192} back this file up somewhere safe; it is your second factor.");
    Ok(())
}
fn cmd_add(kind: AddKind) -> Result<()> {
    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut manifest = vault.load_manifest()?;

    let item = match kind {
        AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } =>
            build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?,
        AddKind::SecureNote { title, body_prompt, group, tags } =>
            build_secure_note_item(title, body_prompt, group, tags)?,
        AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } =>
            build_identity_item(title, full_name, email, phone, date_of_birth, group, tags)?,
        AddKind::Card { title, holder, expiry, kind, group, tags } =>
            build_card_item(title, holder, expiry, kind, group, tags)?,
        AddKind::Key { title, label, algorithm, group, tags } =>
            build_key_item(title, label, algorithm, group, tags)?,
        AddKind::Document { title, file, group, tags } =>
            build_document_item(&vault, title, file, group, tags)?,
        AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } =>
            build_totp_item(title, issuer, label, secret, period, digits, algorithm, group, tags)?,
    };

    vault.save_item(&item)?;
    manifest.upsert(&item);
    vault.save_manifest(&manifest)?;
    refresh_groups_cache(vault.root(), &manifest);

    let mut paths: Vec<String> = vec![
        format!("items/{}.enc", item.id.as_str()),
        "manifest.enc".into(),
    ];
    for att in &item.attachments {
        paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str()));
    }
    let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
    commit_paths(&vault, &format!("add: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &path_refs)?;

    eprintln!("Added: {} (id={})", item.title, item.id.as_str());
    Ok(())
}

// --- Per-type item builders, one per AddKind variant. Each returns a
//     fully-populated Item; cmd_add handles the common save/manifest/commit
//     wrap-up. Document is the only builder that needs the unlocked vault
//     (for attachment-cap settings + writing the encrypted blob alongside
//     the item).

fn build_login_item(
    title: Option<String>,
    username: Option<String>,
    url: Option<String>,
    password_prompt: bool,
    password: Option<String>,
    group: Option<String>,
    tags: Vec<String>,
    favorite: bool,
    totp_qr: Option<PathBuf>,
) -> Result<relicario_core::Item> {
    use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind};
    use relicario_core::{Item, ItemCore};
    use zeroize::Zeroizing;

    let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
    let username = username.or_else(|| prompt_optional("Username").ok().flatten());
    let url = url.or_else(|| prompt_optional("URL").ok().flatten());
    let parsed_url = match url {
        Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
        None => None,
    };
    let password = if let Some(p) = password {
        Some(Zeroizing::new(p))
    } else if password_prompt {
        Some(Zeroizing::new(prompt_secret("Password: ")?))
    } else {
        None
    };
    let totp = if let Some(path) = totp_qr {
        let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
        let secret_bytes = base32_decode_lenient(&secret_b32)?;
        Some(TotpConfig {
            secret: Zeroizing::new(secret_bytes),
            algorithm: TotpAlgorithm::Sha1,
            digits: 6,
            period_seconds: 30,
            kind: TotpKind::Totp,
        })
    } else {
        None
    };
    let mut item = Item::new(title, ItemCore::Login(LoginCore {
        username, password, url: parsed_url, totp,
    }));
    item.group = group;
    item.tags = tags;
    item.favorite = favorite;
    Ok(item)
}

fn build_secure_note_item(
    title: Option<String>,
    body_prompt: bool,
    group: Option<String>,
    tags: Vec<String>,
) -> Result<relicario_core::Item> {
    use relicario_core::item_types::SecureNoteCore;
    use relicario_core::{Item, ItemCore};
    use zeroize::Zeroizing;

    let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
    let body = if body_prompt {
        eprintln!("Enter note body; end with Ctrl-D on a blank line:");
        let mut s = String::new();
        std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
        s
    } else {
        prompt("Body")?
    };
    let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore {
        body: Zeroizing::new(body),
    }));
    item.group = group;
    item.tags = tags;
    Ok(item)
}

fn build_identity_item(
    title: Option<String>,
    full_name: Option<String>,
    email: Option<String>,
    phone: Option<String>,
    date_of_birth: Option<String>,
    group: Option<String>,
    tags: Vec<String>,
) -> Result<relicario_core::Item> {
    use relicario_core::item_types::IdentityCore;
    use relicario_core::{Item, ItemCore};

    let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
    let dob = match date_of_birth {
        Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
            .with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
        None => None,
    };
    let mut item = Item::new(title, ItemCore::Identity(IdentityCore {
        full_name, address: None, phone, email, date_of_birth: dob,
    }));
    item.group = group;
    item.tags = tags;
    Ok(item)
}

fn build_card_item(
    title: Option<String>,
    holder: Option<String>,
    expiry: Option<String>,
    kind: String,
    group: Option<String>,
    tags: Vec<String>,
) -> Result<relicario_core::Item> {
    use relicario_core::item_types::{CardCore, CardKind};
    use relicario_core::{Item, ItemCore};
    use zeroize::Zeroizing;

    let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
    let number = Zeroizing::new(prompt_secret("Card number: ")?);
    let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
    let cvv = if cvv.is_empty() { None } else { Some(cvv) };
    let pin = Zeroizing::new(prompt_secret("PIN (blank to skip): ")?);
    let pin = if pin.is_empty() { None } else { Some(pin) };

    let parsed_expiry = match expiry {
        Some(s) => Some(parse_month_year(&s)?),
        None => None,
    };
    let parsed_kind = match kind.as_str() {
        "credit"  => CardKind::Credit,
        "debit"   => CardKind::Debit,
        "gift"    => CardKind::Gift,
        "loyalty" => CardKind::Loyalty,
        "other"   => CardKind::Other,
        other => anyhow::bail!("unknown card kind: {other}"),
    };

    let mut item = Item::new(title, ItemCore::Card(CardCore {
        number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parsed_kind,
    }));
    item.group = group;
    item.tags = tags;
    Ok(item)
}

fn build_key_item(
    title: Option<String>,
    label: Option<String>,
    algorithm: Option<String>,
    group: Option<String>,
    tags: Vec<String>,
) -> Result<relicario_core::Item> {
    use relicario_core::item_types::KeyCore;
    use relicario_core::{Item, ItemCore};
    use zeroize::Zeroizing;

    let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
    eprintln!("Paste key material; end with Ctrl-D on a blank line:");
    let mut key_material = String::new();
    std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
    if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
    let public_key = prompt_optional("Public key (blank to skip)")?;

    let mut item = Item::new(title, ItemCore::Key(KeyCore {
        key_material: Zeroizing::new(key_material),
        label, public_key, algorithm,
    }));
    item.group = group;
    item.tags = tags;
    Ok(item)
}

fn build_document_item(
    vault: &crate::session::UnlockedVault,
    title: Option<String>,
    file: PathBuf,
    group: Option<String>,
    tags: Vec<String>,
) -> Result<relicario_core::Item> {
    use relicario_core::item_types::DocumentCore;
    use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
    use std::fs;

    let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
    let bytes = fs::read(&file)
        .with_context(|| format!("failed to read {}", file.display()))?;
    let caps = vault.load_settings()?.attachment_caps;
    let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;

    let filename = file.file_name()
        .ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
        .to_string_lossy()
        .into_owned();
    let mime_type = guess_mime(&filename);

    let primary_attachment = enc.id.clone();
    let mut item = Item::new(title, ItemCore::Document(DocumentCore {
        filename: filename.clone(),
        mime_type: mime_type.clone(),
        primary_attachment: primary_attachment.clone(),
    }));
    item.group = group;
    item.tags = tags;
    item.attachments.push(AttachmentRef {
        id: primary_attachment.clone(),
        filename, mime_type,
        size: bytes.len() as u64,
        created: item.created,
    });

    let att_dir = vault.root().join("attachments").join(item.id.as_str());
    fs::create_dir_all(&att_dir)?;
    fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?;
    Ok(item)
}

fn build_totp_item(
    title: Option<String>,
    issuer: Option<String>,
    label: Option<String>,
    secret: Option<String>,
    period: u32,
    digits: u8,
    algorithm: String,
    group: Option<String>,
    tags: Vec<String>,
) -> Result<relicario_core::Item> {
    use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind};
    use relicario_core::{Item, ItemCore};
    use zeroize::Zeroizing;

    let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
    let secret_b32 = match secret {
        Some(s) => s,
        None => prompt_secret("TOTP secret (base32): ")?,
    };
    let secret_bytes = base32_decode_lenient(&secret_b32)?;
    let algo = match algorithm.to_ascii_lowercase().as_str() {
        "sha1"   => TotpAlgorithm::Sha1,
        "sha256" => TotpAlgorithm::Sha256,
        "sha512" => TotpAlgorithm::Sha512,
        other => anyhow::bail!("unknown algorithm: {other}"),
    };

    let mut item = Item::new(title, ItemCore::Totp(TotpCore {
        config: TotpConfig {
            secret: Zeroizing::new(secret_bytes),
            algorithm: algo,
            digits,
            period_seconds: period,
            kind: TotpKind::Totp,
        },
        issuer, label,
    }));
    item.group = group;
    item.tags = tags;
    Ok(item)
}

fn prompt(label: &str) -> Result<String> {
    eprint!("{label}: ");
    std::io::Write::flush(&mut std::io::stderr())?;
    let mut s = String::new();
    std::io::stdin().read_line(&mut s)?;
    let trimmed = s.trim().to_string();
    if trimmed.is_empty() { anyhow::bail!("{label} required"); }
    Ok(trimmed)
}

fn prompt_optional(label: &str) -> Result<Option<String>> {
    eprint!("{label} (leave blank to skip): ");
    std::io::Write::flush(&mut std::io::stderr())?;
    let mut s = String::new();
    std::io::stdin().read_line(&mut s)?;
    let trimmed = s.trim().to_string();
    Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}

fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
    // Accepts MM/YYYY or MM-YYYY or MM/YY.
    let (m_str, y_str) = s.split_once(|c: char| c == '/' || c == '-')
        .ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
    let month: u8 = m_str.parse().context("invalid month")?;
    let year: u16 = if y_str.len() == 2 {
        2000 + y_str.parse::<u16>().context("invalid 2-digit year")?
    } else {
        y_str.parse().context("invalid year")?
    };
    Ok(relicario_core::MonthYear { month, year })
}

fn guess_mime(filename: &str) -> String {
    let lower = filename.to_ascii_lowercase();
    match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") {
        "pdf"        => "application/pdf",
        "png"        => "image/png",
        "jpg" | "jpeg" => "image/jpeg",
        "txt"        => "text/plain",
        "json"       => "application/json",
        _            => "application/octet-stream",
    }.to_string()
}

fn base32_decode_lenient(s: &str) -> Result<Vec<u8>> {
    let cleaned: String = s.chars()
        .filter(|c| !c.is_whitespace())
        .collect::<String>()
        .to_ascii_uppercase()
        .trim_end_matches('=')
        .to_string();
    let padded = {
        let rem = cleaned.len() % 8;
        if rem == 0 { cleaned } else { format!("{}{}", cleaned, "=".repeat(8 - rem)) }
    };
    data_encoding::BASE32.decode(padded.as_bytes())
        .map_err(|e| anyhow::anyhow!("invalid base32: {e}"))
}

fn commit_paths(vault: &crate::session::UnlockedVault, message: &str, paths: &[&str]) -> Result<()> {
    let mut args: Vec<&str> = vec!["add"];
    args.extend_from_slice(paths);
    let status = crate::helpers::git_command(vault.root(), &args).status()?;
    if !status.success() { anyhow::bail!("git add failed"); }
    let status = crate::helpers::git_command(vault.root(), &["commit", "-m", message]).status()?;
    if !status.success() { anyhow::bail!("git commit failed"); }
    Ok(())
}

fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
    use relicario_core::ItemCore;
    use zeroize::Zeroizing;

    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let manifest = vault.load_manifest()?;
    refresh_groups_cache(vault.root(), &manifest);
    let entry = resolve_query(&manifest, &query)?;
    let item = vault.load_item(&entry.id)?;

    println!("ID:       {}", item.id.as_str());
    println!("Title:    {}", item.title);
    println!("Type:     {:?}", item.r#type);
    if let Some(g) = &item.group { println!("Group:    {g}"); }
    if !item.tags.is_empty() { println!("Tags:     {}", item.tags.join(", ")); }
    println!("Created:  {}", crate::helpers::iso8601(item.created));
    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}"); }
            if let Some(t) = &l.totp {
                if show {
                    println!("TOTP:     {}", data_encoding::BASE32.encode(&*t.secret));
                } else {
                    println!("TOTP:     ****  (use --show to reveal)");
                }
            }
            if let Some(p) = &l.password { Some(p.clone()) } else { None }
        }
        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}"); }
            if let Some(v) = &i.date_of_birth { println!("DOB:      {v}"); }
            None
        }
        ItemCore::Card(c) => {
            if let Some(h) = &c.holder { println!("Holder:   {h}"); }
            if let Some(e) = &c.expiry { println!("Expiry:   {:02}/{}", e.month, e.year); }
            println!("Kind:     {:?}", c.kind);
            c.number.clone()
        }
        ItemCore::Key(k) => {
            if let Some(l) = &k.label { println!("Label:    {l}"); }
            if let Some(a) = &k.algorithm { println!("Algo:     {a}"); }
            if let Some(pk) = &k.public_key { println!("Pubkey:   {pk}"); }
            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}"); }
            println!("Period:   {}s", t.config.period_seconds);
            println!("Digits:   {}", t.config.digits);
            None
        }
    };

    if let Some(secret) = primary_secret {
        if show {
            println!("Secret:   {}", secret.as_str());
        } else {
            println!("Secret:   ********  (use --show to reveal, --copy to clipboard)");
        }
        if copy {
            copy_to_clipboard_then_clear(&secret)?;
            eprintln!("Copied to clipboard (auto-clears in 30s).");
        }
    }

    Ok(())
}

fn resolve_query<'a>(
    manifest: &'a relicario_core::Manifest,
    query: &str,
) -> Result<&'a relicario_core::ManifestEntry> {
    if let Some(entry) = manifest.items.values().find(|e| e.id.as_str() == query) {
        return Ok(entry);
    }
    let hits: Vec<_> = manifest.search(query);
    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(", "))
        }
    }
}

fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing<String>) -> Result<()> {
    use arboard::Clipboard;
    let mut cb = Clipboard::new().context("failed to access clipboard")?;
    cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?;
    let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned());
    // Unconditional clear (audit M6): spawn a detached thread that waits 30s
    // and then rewrites the clipboard with empty string. Even if the user
    // copies something else in the interim, we still overwrite once.
    std::thread::spawn(move || {
        std::thread::sleep(std::time::Duration::from_secs(30));
        if let Ok(mut cb) = Clipboard::new() {
            let _ = cb.set_text(String::new());
            drop(cleared_copy); // zeroize the detached copy
        }
    });
    Ok(())
}
fn cmd_list(
    type_filter: Option<String>,
    group_filter: Option<String>,
    tag_filter: Option<String>,
    trashed: bool,
) -> Result<()> {
    use relicario_core::ItemType;

    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let manifest = vault.load_manifest()?;
    refresh_groups_cache(vault.root(), &manifest);

    let parsed_type: Option<ItemType> = match type_filter.as_deref() {
        None => None,
        Some("login") => Some(ItemType::Login),
        Some("secure_note") | Some("note") => Some(ItemType::SecureNote),
        Some("identity") => Some(ItemType::Identity),
        Some("card") => Some(ItemType::Card),
        Some("key") => Some(ItemType::Key),
        Some("document") => Some(ItemType::Document),
        Some("totp") => Some(ItemType::Totp),
        Some(other) => anyhow::bail!("unknown type filter: {other}"),
    };

    let mut entries: Vec<_> = manifest.items.values()
        .filter(|e| {
            if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() }
        })
        .filter(|e| match parsed_type {
            Some(t) => e.r#type == t,
            None => true,
        })
        .filter(|e| group_filter.as_ref().map_or(true, |g| e.group.as_deref() == Some(g.as_str())))
        .filter(|e| tag_filter.as_ref().map_or(true, |t| e.tags.iter().any(|x| x == t)))
        .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}  {:<6}  {}", "ID", "TYPE", "FAV", "TITLE");
    for e in entries {
        let fav = if e.favorite { "  *" } else { "" };
        println!("{:<16}  {:<14}  {:<6}  {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
    }
    Ok(())
}
fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
    use relicario_core::time::now_unix;
    use relicario_core::ItemCore;

    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let id = entry.id.clone();
    let _ = entry;
    let mut item = vault.load_item(&id)?;

    eprintln!("Editing: {} ({})  — leave a prompt blank to keep the current value.",
              item.title, item.id.as_str());

    if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; }
    if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); }
    if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? {
        item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
    }

    let history = &mut item.field_history;
    match &mut item.core {
        ItemCore::Login(l)      => edit_login(l, history, totp_qr)?,
        ItemCore::SecureNote(n) => edit_secure_note(n, history)?,
        ItemCore::Identity(i)   => edit_identity(i)?,
        ItemCore::Card(c)       => edit_card(c, history)?,
        ItemCore::Key(k)        => edit_key(k, history)?,
        ItemCore::Document(_)   => edit_document_message(),
        ItemCore::Totp(t)       => edit_totp(t, history)?,
    }

    item.modified = now_unix();
    vault.save_item(&item)?;
    manifest.upsert(&item);
    vault.save_manifest(&manifest)?;
    refresh_groups_cache(vault.root(), &manifest);
    commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
                 &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
    eprintln!("Updated {}", item.id.as_str());
    Ok(())
}

// --- Per-type edit handlers. Each mutates its core slice in place; the ones
//     that touch history-tracked fields take the item's field_history map so
//     they can record the prior value alongside the change.

type FieldHistory = std::collections::HashMap<
    relicario_core::FieldId,
    Vec<relicario_core::item::FieldHistoryEntry>,
>;

fn edit_login(
    l: &mut relicario_core::item_types::LoginCore,
    history: &mut FieldHistory,
    totp_qr: Option<PathBuf>,
) -> Result<()> {
    use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
    use zeroize::Zeroizing;
    if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
    if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
        l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
    }
    if prompt_yesno("Change password?")? {
        let old = l.password.clone();
        l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
        if let Some(old_pw) = old {
            push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
        }
    }
    if let Some(path) = totp_qr {
        let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
        let secret_bytes = base32_decode_lenient(&secret_b32)?;
        l.totp = Some(TotpConfig {
            secret: Zeroizing::new(secret_bytes),
            algorithm: TotpAlgorithm::Sha1,
            digits: 6,
            period_seconds: 30,
            kind: TotpKind::Totp,
        });
        eprintln!("TOTP secret set from QR image.");
    }
    Ok(())
}

fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
    use zeroize::Zeroizing;
    if prompt_yesno("Edit body?")? {
        let old = n.body.clone();
        eprintln!("Enter new body; end with Ctrl-D:");
        let mut s = String::new();
        std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
        n.body = Zeroizing::new(s);
        push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
    }
    Ok(())
}

fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
    if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
    if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
    if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
    Ok(())
}

fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
    use zeroize::Zeroizing;
    if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
    if prompt_yesno("Change card number?")? {
        let old = c.number.clone();
        c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
        if let Some(o) = old {
            push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
        }
    }
    Ok(())
}

fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
    use zeroize::Zeroizing;
    if prompt_yesno("Replace key material?")? {
        eprintln!("Paste new key material; end with Ctrl-D:");
        let mut s = String::new();
        std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
        let old = k.key_material.clone();
        k.key_material = Zeroizing::new(s);
        push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
    }
    Ok(())
}

fn edit_document_message() {
    eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
}

fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
    use zeroize::Zeroizing;
    if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
    if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
    if prompt_yesno("Change TOTP secret?")? {
        let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
        let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
        let new_bytes = base32_decode_lenient(&new_b32)?;
        t.config.secret = Zeroizing::new(new_bytes);
        push_history(history, "totp_secret", Zeroizing::new(old_b32));
    }
    Ok(())
}

fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
    eprint!("{label} [{current}]: ");
    std::io::Write::flush(&mut std::io::stderr())?;
    let mut s = String::new();
    std::io::stdin().read_line(&mut s)?;
    let trimmed = s.trim().to_string();
    Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}

fn prompt_keep_opt(label: &str, current: Option<&str>) -> Result<Option<String>> {
    let display = current.unwrap_or("(none)");
    eprint!("{label} [{display}]: ");
    std::io::Write::flush(&mut std::io::stderr())?;
    let mut s = String::new();
    std::io::stdin().read_line(&mut s)?;
    let trimmed = s.trim().to_string();
    Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}

fn prompt_yesno(label: &str) -> Result<bool> {
    eprint!("{label} [y/N] ");
    std::io::Write::flush(&mut std::io::stderr())?;
    let mut s = String::new();
    std::io::stdin().read_line(&mut s)?;
    Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
}

fn push_history(
    history: &mut std::collections::HashMap<relicario_core::FieldId, Vec<relicario_core::item::FieldHistoryEntry>>,
    synthetic_key: &str,
    old_value: zeroize::Zeroizing<String>,
) {
    use relicario_core::item::FieldHistoryEntry;
    use relicario_core::time::now_unix;
    // Synthetic FieldId for core-level fields — stable per-item (prefixed so
    // custom-field UUIDs can't collide).
    let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
    history.entry(fid).or_default().push(FieldHistoryEntry {
        value: old_value,
        replaced_at: now_unix(),
    });
}

fn cmd_history(query: String, show: bool, field: Option<String>) -> Result<()> {
    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let item = vault.load_item(&entry.id)?;

    println!("History for {} ({})", item.title, item.id.as_str());
    println!();

    // Filter and sort the field-id keys so output is deterministic.
    let mut keys: Vec<&relicario_core::FieldId> = item.field_history.keys().collect();
    keys.sort_by(|a, b| a.0.cmp(&b.0));

    let mut printed_any = false;
    for fid in keys {
        let display_name = fid.0.strip_prefix("core:").unwrap_or(&fid.0);
        if let Some(filter) = &field {
            if display_name != filter && fid.0 != *filter { continue; }
        }
        let entries = &item.field_history[fid];
        if entries.is_empty() { continue; }
        printed_any = true;

        println!("{display_name} ({} {})",
                 entries.len(),
                 if entries.len() == 1 { "entry" } else { "entries" });
        for (i, e) in entries.iter().enumerate() {
            let ts = crate::helpers::iso8601(e.replaced_at);
            if show {
                println!("  [{i}] {ts}  {}", e.value.as_str());
            } else {
                println!("  [{i}] {ts}  ********");
            }
        }
        println!();
    }

    if !printed_any {
        if field.is_some() {
            println!("no history for the requested field");
        } else {
            println!("no history captured for this item");
        }
    } else if !show {
        println!("(use --show to reveal values)");
    }

    Ok(())
}

fn cmd_rm(query: String) -> Result<()> {
    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let id = entry.id.clone();
    let _ = entry;
    let mut item = vault.load_item(&id)?;
    item.soft_delete();
    vault.save_item(&item)?;
    manifest.upsert(&item);
    vault.save_manifest(&manifest)?;
    refresh_groups_cache(vault.root(), &manifest);
    commit_paths(&vault, &format!("trash: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
                 &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
    eprintln!("Moved to trash: {}", item.title);
    Ok(())
}

fn cmd_restore(query: String) -> Result<()> {
    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let id = entry.id.clone();
    let _ = entry;
    let mut item = vault.load_item(&id)?;
    item.restore();
    vault.save_item(&item)?;
    manifest.upsert(&item);
    vault.save_manifest(&manifest)?;
    refresh_groups_cache(vault.root(), &manifest);
    commit_paths(&vault, &format!("restore: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
                 &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
    eprintln!("Restored: {}", item.title);
    Ok(())
}

/// Inner purge: assumes vault is already unlocked and manifest is loaded.
/// Caller is responsible for saving the manifest and committing afterwards.
fn purge_item(
    vault: &crate::session::UnlockedVault,
    manifest: &mut relicario_core::Manifest,
    id: &relicario_core::ItemId,
    title: &str,
) -> Result<()> {
    use std::fs;

    let item_path = vault.item_path(id);
    if item_path.exists() { fs::remove_file(&item_path)?; }
    let att_dir = vault.root().join("attachments").join(id.as_str());
    if att_dir.exists() { fs::remove_dir_all(&att_dir)?; }
    manifest.remove(id);

    let _ = crate::helpers::git_command(vault.root(), &["rm", "-rf", "--ignore-unmatch",
        &format!("items/{}.enc", id.as_str()),
        &format!("attachments/{}", id.as_str()),
    ]).status()?;
    // Note: caller adds+commits manifest.enc after processing all purges.
    eprintln!("Purged: {title}");
    Ok(())
}

fn cmd_purge(query: String) -> Result<()> {
    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let id = entry.id.clone();
    let title = entry.title.clone();
    let _ = entry;

    purge_item(&vault, &mut manifest, &id, &title)?;
    vault.save_manifest(&manifest)?;
    refresh_groups_cache(vault.root(), &manifest);

    let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?;
    if !status.success() { anyhow::bail!("git add manifest.enc failed"); }
    let status = crate::helpers::git_command(vault.root(),
        &["commit", "-m", &format!("purge: {} ({})", title, id.as_str())]).status()?;
    if !status.success() { anyhow::bail!("git commit failed"); }
    Ok(())
}

fn cmd_trash(action: TrashAction) -> Result<()> {
    match action {
        TrashAction::List => cmd_list(None, None, None, true),
        TrashAction::Empty => cmd_trash_empty(),
    }
}

fn cmd_backup(action: BackupAction) -> Result<()> {
    match action {
        BackupAction::Export { out, include_image, image, no_history } => {
            cmd_backup_export(out, include_image, image, no_history)
        }
        BackupAction::Restore { input, target } => cmd_backup_restore(input, target),
    }
}

fn cmd_backup_export(
    out: PathBuf,
    include_image: bool,
    image: Option<PathBuf>,
    no_history: bool,
) -> Result<()> {
    use std::fs;
    use relicario_core::{backup, validate_passphrase_strength};
    use zeroize::Zeroizing;

    let root = crate::helpers::vault_dir()?;

    // Backup passphrase — prompt twice, gate on zxcvbn (audit H3).
    let passphrase = if let Some(p) = test_backup_passphrase_override() {
        Zeroizing::new(p)
    } else {
        Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
    };
    let confirm = if test_backup_passphrase_override().is_some() {
        passphrase.clone()
    } else {
        Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
    };
    if passphrase.as_str() != confirm.as_str() {
        anyhow::bail!("passphrases do not match");
    }
    if let Err(e) = validate_passphrase_strength(&passphrase) {
        anyhow::bail!("backup {}. Choose a longer or more entropic phrase.", e);
    }

    // Read everything from disk that the envelope needs.
    let salt = fs::read(root.join(".relicario").join("salt"))
        .with_context(|| "failed to read .relicario/salt")?;
    let params_json = fs::read_to_string(root.join(".relicario").join("params.json"))
        .with_context(|| "failed to read .relicario/params.json")?;
    // devices.json was removed in the B1 security audit fix; fall back to
    // an empty array so backups of post-B1 vaults still pack cleanly.
    // Task 12 will remove the devices field from the backup format entirely.
    let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json"))
        .unwrap_or_else(|_| "[]".to_string());
    let manifest_enc = fs::read(root.join("manifest.enc"))
        .with_context(|| "failed to read manifest.enc")?;
    let settings_enc = fs::read(root.join("settings.enc"))
        .with_context(|| "failed to read settings.enc")?;

    // Items.
    let mut item_files = Vec::new();
    let items_dir = root.join("items");
    if items_dir.is_dir() {
        for entry in fs::read_dir(&items_dir)? {
            let p = entry?.path();
            if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; }
            let id = p.file_stem()
                .and_then(|s| s.to_str())
                .ok_or_else(|| anyhow::anyhow!("bad item filename: {}", p.display()))?
                .to_string();
            let bytes = fs::read(&p)?;
            item_files.push((id, bytes));
        }
    }

    // Attachments. Layout: attachments/<item_id>/<aid>.enc
    let mut attach_files = Vec::new();
    let attach_dir = root.join("attachments");
    if attach_dir.is_dir() {
        for entry in fs::read_dir(&attach_dir)? {
            let item_dir = entry?.path();
            if !item_dir.is_dir() { continue; }
            let item_id = item_dir.file_name()
                .and_then(|s| s.to_str())
                .ok_or_else(|| anyhow::anyhow!("bad attachment dir: {}", item_dir.display()))?
                .to_string();
            for sub in fs::read_dir(&item_dir)? {
                let p = sub?.path();
                if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; }
                let aid = p.file_stem()
                    .and_then(|s| s.to_str())
                    .ok_or_else(|| anyhow::anyhow!("bad attachment filename: {}", p.display()))?
                    .to_string();
                let bytes = fs::read(&p)?;
                attach_files.push((item_id.clone(), aid, bytes));
            }
        }
    }

    // Optional reference image.
    let image_bytes = if include_image {
        let path = match image {
            Some(p) => p,
            None => crate::session::get_image_path()?,
        };
        Some(fs::read(&path)
            .with_context(|| format!("failed to read reference image {}", path.display()))?)
    } else {
        None
    };

    // Optional .git/ tar.
    let git_archive = if no_history { None } else { Some(tar_directory(&root.join(".git"))?) };

    let items_refs: Vec<backup::BackupItem> = item_files.iter()
        .map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes })
        .collect();
    let attach_refs: Vec<backup::BackupAttachment> = attach_files.iter()
        .map(|(iid, aid, bytes)| backup::BackupAttachment {
            item_id: iid.clone(),
            attachment_id: aid.clone(),
            ciphertext: bytes,
        })
        .collect();

    let input = backup::BackupInput {
        salt: &salt,
        params_json: &params_json,
        devices_json: &devices_json,
        manifest_enc: &manifest_enc,
        settings_enc: &settings_enc,
        items: items_refs,
        attachments: attach_refs,
        reference_jpg: image_bytes.as_deref(),
        git_archive: git_archive.as_deref(),
    };

    let bytes = backup::pack_backup(input, &passphrase)?;

    // atomic_write via the existing pattern: write `.tmp`, rename.
    let tmp = {
        let mut t = out.as_os_str().to_owned();
        t.push(".tmp");
        PathBuf::from(t)
    };
    fs::write(&tmp, &bytes)
        .with_context(|| format!("failed to write {}", tmp.display()))?;
    fs::rename(&tmp, &out)
        .with_context(|| format!("failed to rename {}", out.display()))?;

    // Marker file for `cmd_status`. Format: ISO-8601 UTC line.
    let now_iso = crate::helpers::iso8601(relicario_core::now_unix());
    fs::write(root.join(".relicario").join("last_backup"), format!("{now_iso}\n"))?;

    let mib = (bytes.len() as f64) / (1024.0 * 1024.0);
    eprintln!(
        "Wrote {} ({:.2} MiB). Delete after restore is verified.",
        out.display(), mib
    );
    Ok(())
}

/// Tar a directory into an in-memory `Vec<u8>`. Used for `.git/` bundling.
fn tar_directory(dir: &std::path::Path) -> Result<Vec<u8>> {
    let mut buf = Vec::new();
    {
        let mut builder = tar::Builder::new(&mut buf);
        builder.append_dir_all(".", dir)
            .with_context(|| format!("failed to tar {}", dir.display()))?;
        builder.finish().with_context(|| "failed to finalize git tar")?;
    }
    Ok(buf)
}

fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
    use std::fs;
    use relicario_core::backup;
    use relicario_core::{ItemId, AttachmentId};
    use zeroize::Zeroizing;

    let target = if target.is_absolute() {
        target
    } else {
        std::env::current_dir()?.join(&target)
    };

    if target.join(".relicario").exists() {
        anyhow::bail!(
            "target dir already contains a Relicario vault; restore refuses to overwrite — use an empty directory: {}",
            target.display()
        );
    }
    fs::create_dir_all(&target)
        .with_context(|| format!("failed to create target {}", target.display()))?;

    // Read input file.
    let bytes = fs::read(&input)
        .with_context(|| format!("failed to read backup file {}", input.display()))?;

    // Backup passphrase prompt.
    let passphrase = if let Some(p) = test_backup_passphrase_override() {
        Zeroizing::new(p)
    } else {
        Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
    };

    let unpacked = backup::unpack_backup(&bytes, &passphrase)
        .map_err(|e| match e {
            relicario_core::RelicarioError::Decrypt =>
                anyhow::anyhow!("wrong backup passphrase, or the file is corrupt"),
            other => anyhow::anyhow!(other),
        })?;

    // Write vault layout.
    let relicario_dir = target.join(".relicario");
    fs::create_dir_all(&relicario_dir)?;
    fs::create_dir_all(target.join("items"))?;
    fs::create_dir_all(target.join("attachments"))?;

    fs::write(relicario_dir.join("salt"), unpacked.salt)?;
    fs::write(relicario_dir.join("params.json"), &unpacked.params_json)?;
    fs::write(relicario_dir.join("devices.json"), &unpacked.devices_json)?;
    fs::write(target.join("manifest.enc"), &unpacked.manifest_enc)?;
    fs::write(target.join("settings.enc"), &unpacked.settings_enc)?;

    for item in &unpacked.items {
        let item_id = ItemId(item.id.clone());
        if !item_id.is_valid() {
            anyhow::bail!("invalid item ID in backup: {} (path traversal blocked)", item.id);
        }
        fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?;
    }
    for a in &unpacked.attachments {
        let item_id = ItemId(a.item_id.clone());
        let att_id = AttachmentId(a.attachment_id.clone());
        if !item_id.is_valid() || !att_id.is_valid() {
            anyhow::bail!("invalid attachment ID in backup (path traversal blocked)");
        }
        let dir = target.join("attachments").join(&a.item_id);
        fs::create_dir_all(&dir)?;
        fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?;
    }

    // Reference image (if present).
    if let Some(jpg) = &unpacked.reference_jpg {
        let path = target.join("reference.jpg");
        fs::write(&path, jpg)
            .with_context(|| format!("failed to write reference image {}", path.display()))?;
    }

    // .git/ history.
    if let Some(tar_bytes) = &unpacked.git_archive {
        let mut archive = tar::Archive::new(tar_bytes.as_slice());
        archive.unpack(target.join(".git"))
            .with_context(|| "failed to untar .git/")?;
    } else {
        // No history bundled — start a fresh git repo.
        let status = crate::helpers::git_command(&target, &["init"]).status()?;
        if !status.success() { anyhow::bail!("git init failed"); }

        // .gitignore — exclude reference image if present.
        if target.join("reference.jpg").exists() {
            fs::write(target.join(".gitignore"), "reference.jpg\n")?;
        }

        let _ = crate::helpers::git_command(&target, &["add", "."]).status()?;
        let now_iso = crate::helpers::iso8601(relicario_core::now_unix());
        let msg = format!("restore from backup {now_iso}");
        let _ = crate::helpers::git_command(&target, &["commit", "-m", &msg]).status()?;
    }

    eprintln!(
        "Restored vault to {}. Unlock with your passphrase + reference image.",
        target.display()
    );
    Ok(())
}

fn cmd_import(action: ImportAction) -> Result<()> {
    match action {
        ImportAction::Lastpass { csv } => cmd_import_lastpass(csv),
    }
}

fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> {
    use std::fs;
    use relicario_core::import_lastpass::parse_lastpass_csv;

    let csv_bytes = fs::read(&csv_path)
        .with_context(|| format!("failed to read CSV {}", csv_path.display()))?;

    let (items, warnings) = parse_lastpass_csv(&csv_bytes)?;

    if items.is_empty() {
        // Print all warnings so the user sees why nothing imported.
        for w in &warnings {
            print_warning(w);
        }
        bail!(
            "imported 0 items from {} — see warnings above",
            csv_path.display()
        );
    }

    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut manifest = vault.load_manifest()?;

    let total = items.len();
    let mut written_paths: Vec<String> = Vec::with_capacity(items.len() + 1);

    for (idx, item) in items.iter().enumerate() {
        vault.save_item(item)?;
        manifest.upsert(item);
        written_paths.push(format!("items/{}.enc", item.id.as_str()));

        let n = idx + 1;
        if n % 50 == 0 || n == total {
            eprintln!("[{n}/{total}] importing...");
        }
    }

    vault.save_manifest(&manifest)?;
    written_paths.push("manifest.enc".into());

    let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect();
    let csv_filename = csv_path
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("lastpass.csv");
    commit_paths(
        &vault,
        &format!("import: {} items from LastPass ({})", total, csv_filename),
        &path_refs,
    )?;

    for w in &warnings {
        print_warning(w);
    }
    // Counts only true skips, not partial imports. Coupled by convention to
    // the parser's warning message strings: skip messages end in "— skipped",
    // partial-import messages say "imported without TOTP" / "imported without URL".
    // If a future warning uses the word "skipped" in any other sense, this filter
    // will need to switch to an enum tag (see ImportWarning::message).
    eprintln!(
        "Imported {}, skipped {} (see warnings above)",
        total,
        warnings.iter().filter(|w| w.message.contains("skipped")).count()
    );
    Ok(())
}

fn print_warning(w: &relicario_core::import_lastpass::ImportWarning) {
    let prefix = match &w.title {
        Some(t) => format!("row {} ({}):", w.row, t),
        None => format!("row {}:", w.row),
    };
    eprintln!("warning: {prefix} {}", w.message);
}

fn cmd_trash_empty() -> Result<()> {
    use relicario_core::time::now_unix;

    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut manifest = vault.load_manifest()?;
    let settings = vault.load_settings()?;
    let now = now_unix();

    let purgeable: Vec<_> = manifest.items.values()
        .filter(|e| match e.trashed_at {
            Some(t) => settings.trash_retention.should_purge(t, now),
            None => false,
        })
        .map(|e| (e.id.clone(), e.title.clone()))
        .collect();

    if purgeable.is_empty() {
        eprintln!("nothing past retention window");
        return Ok(());
    }

    let mut purged_titles = Vec::new();
    for (id, title) in purgeable {
        purge_item(&vault, &mut manifest, &id, &title)?;
        purged_titles.push(title);
    }

    vault.save_manifest(&manifest)?;
    let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?;
    if !status.success() { anyhow::bail!("git add manifest.enc failed"); }
    let status = crate::helpers::git_command(vault.root(),
        &["commit", "-m", &format!("trash empty: purged {} item(s)", purged_titles.len())]).status()?;
    if !status.success() { anyhow::bail!("git commit failed"); }

    eprintln!("Emptied trash: {} item(s)", purged_titles.len());
    Ok(())
}
fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
    use std::fs;
    use relicario_core::{encrypt_attachment, AttachmentRef};
    use relicario_core::time::now_unix;

    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let id = entry.id.clone();
    let _ = entry;
    let mut item = vault.load_item(&id)?;
    let settings = vault.load_settings()?;
    let caps = settings.attachment_caps;

    if item.attachments.len() as u32 >= caps.per_item_max_count {
        anyhow::bail!("item already has {} attachments (max {})",
                      item.attachments.len(), caps.per_item_max_count);
    }

    let bytes = fs::read(&file)
        .with_context(|| format!("failed to read {}", file.display()))?;

    // Check per-vault total attachment bytes cap (audit I3).
    let current_total: u64 = manifest.items.values()
        .flat_map(|e| &e.attachment_summaries)
        .map(|s| s.size)
        .sum();
    let new_size = bytes.len() as u64;
    let hard_cap = caps.per_vault_hard_cap_bytes;
    let soft_cap = caps.per_vault_soft_cap_bytes;
    if current_total + new_size > hard_cap {
        anyhow::bail!(
            "attachment would exceed vault hard cap ({} + {} > {} bytes)",
            current_total, new_size, hard_cap
        );
    }
    if current_total + new_size > soft_cap {
        eprintln!(
            "warning: vault attachments will exceed soft cap ({} bytes)",
            soft_cap
        );
    }

    let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;

    let filename = file.file_name()
        .ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
        .to_string_lossy()
        .into_owned();
    let mime_type = guess_mime(&filename);
    let aref = AttachmentRef {
        id: enc.id.clone(),
        filename,
        mime_type,
        size: bytes.len() as u64,
        created: now_unix(),
    };

    let att_dir = vault.root().join("attachments").join(item.id.as_str());
    fs::create_dir_all(&att_dir)?;
    fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;

    item.attachments.push(aref);
    item.modified = now_unix();
    vault.save_item(&item)?;
    manifest.upsert(&item);
    vault.save_manifest(&manifest)?;

    let paths = [
        format!("items/{}.enc", item.id.as_str()),
        "manifest.enc".into(),
        format!("attachments/{}/{}.enc", item.id.as_str(), enc.id.as_str()),
    ];
    let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
    commit_paths(&vault, &format!("attach: {} → {} ({})",
                                  crate::helpers::sanitize_for_commit(&file.display().to_string()),
                                  crate::helpers::sanitize_for_commit(&item.title),
                                  item.id.as_str()), &path_refs)?;
    eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str());
    Ok(())
}

fn cmd_attachments(query: String) -> Result<()> {
    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let item = vault.load_item(&entry.id)?;
    if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
    println!("{:<17}  {:>12}  {:<22}  {}", "AID", "SIZE", "MIME", "FILENAME");
    for a in &item.attachments {
        println!("{:<17}  {:>12}  {:<22}  {}", a.id.as_str(), a.size, a.mime_type, a.filename);
    }
    Ok(())
}

fn cmd_extract(query: String, aid: String, out: Option<PathBuf>) -> Result<()> {
    use std::fs;
    use relicario_core::decrypt_attachment;

    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let item = vault.load_item(&entry.id)?;

    let aref = item.attachments.iter().find(|a| a.id.as_str() == aid)
        .ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
    let path = vault.root().join("attachments").join(item.id.as_str())
        .join(format!("{}.enc", aid));
    let bytes = fs::read(&path)
        .with_context(|| format!("failed to read {}", path.display()))?;
    let plaintext = decrypt_attachment(&bytes, vault.key())?;
    let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename));
    fs::write(&out_path, plaintext.as_slice())
        .with_context(|| format!("failed to write {}", out_path.display()))?;
    eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display());
    Ok(())
}
fn cmd_detach(query: String, aid: String) -> Result<()> {
    use std::fs;
    use relicario_core::ItemCore;
    use relicario_core::time::now_unix;

    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut manifest = vault.load_manifest()?;
    let entry = resolve_query(&manifest, &query)?;
    let id = entry.id.clone();
    let _ = entry;
    let mut item = vault.load_item(&id)?;

    let pos = item.attachments.iter().position(|a| a.id.as_str() == aid)
        .ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;

    // Document items keep their primary blob in the core; refuse to orphan it.
    if let ItemCore::Document(d) = &item.core {
        if d.primary_attachment.as_str() == aid {
            anyhow::bail!(
                "cannot detach the primary attachment of a Document item; \
                 use `purge {}` to delete the whole item",
                item.title,
            );
        }
    }

    let removed = item.attachments.remove(pos);
    let blob_path = vault.root().join("attachments").join(item.id.as_str())
        .join(format!("{}.enc", removed.id.as_str()));
    if blob_path.exists() {
        fs::remove_file(&blob_path)
            .with_context(|| format!("failed to delete {}", blob_path.display()))?;
    }

    item.modified = now_unix();
    vault.save_item(&item)?;
    manifest.upsert(&item);
    vault.save_manifest(&manifest)?;

    let item_path = format!("items/{}.enc", item.id.as_str());
    let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
    commit_paths(
        &vault,
        &format!("detach: {} from {} ({})", crate::helpers::sanitize_for_commit(&removed.filename), crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
        &[&item_path, "manifest.enc", &blob_relpath],
    )?;
    eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title);
    Ok(())
}

fn cmd_generate(
    length: Option<u32>,
    bip39: bool,
    words: Option<u32>,
    symbols: Option<String>,
    separator: Option<String>,
) -> Result<()> {
    use relicario_core::{
        generate_passphrase, generate_password, Capitalization, CharClasses,
        GeneratorRequest, SymbolCharset,
    };

    // If we're inside a vault, unlock and pull `generator_defaults`. Outside
    // a vault, this stays a fast standalone CSPRNG tool (no unlock prompt).
    let vault_defaults: Option<GeneratorRequest> = if crate::helpers::vault_dir().is_ok() {
        let vault = crate::session::UnlockedVault::unlock_interactive()?;
        Some(vault.load_settings()?.generator_defaults)
    } else {
        None
    };

    // `--bip39` flag forces Bip39 mode; otherwise use whatever mode the
    // vault default is in (Random when no vault).
    let use_bip39 = bip39 || matches!(vault_defaults, Some(GeneratorRequest::Bip39 { .. }));

    let output = if use_bip39 {
        let (def_words, def_sep, def_cap) = match &vault_defaults {
            Some(GeneratorRequest::Bip39 { word_count, separator, capitalization }) => {
                (*word_count, separator.clone(), *capitalization)
            }
            _ => (5, " ".to_string(), Capitalization::Lower),
        };
        generate_passphrase(&GeneratorRequest::Bip39 {
            word_count: words.unwrap_or(def_words),
            separator: separator.unwrap_or(def_sep),
            capitalization: def_cap,
        })?
    } else {
        let (def_length, def_classes, def_charset) = match &vault_defaults {
            Some(GeneratorRequest::Random { length, classes, symbol_charset }) => {
                (*length, *classes, symbol_charset.clone())
            }
            _ => (
                20,
                CharClasses { lower: true, upper: true, digits: true, symbols: true },
                SymbolCharset::SafeOnly,
            ),
        };
        let symbol_charset = match symbols.as_deref() {
            None => def_charset,
            Some("safe") => SymbolCharset::SafeOnly,
            Some("extended") => SymbolCharset::Extended,
            Some(other) => SymbolCharset::Custom(other.to_string()),
        };
        generate_password(&GeneratorRequest::Random {
            length: length.unwrap_or(def_length),
            classes: def_classes,
            symbol_charset,
        })?
    };

    println!("{}", output.as_str());
    Ok(())
}
fn cmd_settings(action: SettingsAction) -> Result<()> {
    use relicario_core::{
        Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
        SymbolCharset, TrashRetention,
    };

    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let mut settings = vault.load_settings()?;

    match action {
        SettingsAction::Show => {
            println!("{}", serde_json::to_string_pretty(&settings)?);
            return Ok(());
        }
        SettingsAction::TrashRetention { days, forever } => {
            settings.trash_retention = match (days, forever) {
                (Some(d), false) => TrashRetention::Days(d),
                (None, true) => TrashRetention::Forever,
                _ => anyhow::bail!("specify exactly one of --days or --forever"),
            };
        }
        SettingsAction::HistoryRetention { last_n, days, forever } => {
            settings.field_history_retention = match (last_n, days, forever) {
                (Some(n), None, false) => HistoryRetention::LastN(n),
                (None, Some(d), false) => HistoryRetention::Days(d),
                (None, None, true) => HistoryRetention::Forever,
                _ => anyhow::bail!("specify exactly one of --last-n / --days / --forever"),
            };
        }
        SettingsAction::AttachmentCap {
            per_attachment_max_bytes, per_item_max_count,
            per_vault_soft_cap_bytes, per_vault_hard_cap_bytes,
        } => {
            if let Some(v) = per_attachment_max_bytes { settings.attachment_caps.per_attachment_max_bytes = v; }
            if let Some(v) = per_item_max_count { settings.attachment_caps.per_item_max_count = v; }
            if let Some(v) = per_vault_soft_cap_bytes { settings.attachment_caps.per_vault_soft_cap_bytes = v; }
            if let Some(v) = per_vault_hard_cap_bytes { settings.attachment_caps.per_vault_hard_cap_bytes = v; }
        }
        SettingsAction::GeneratorDefaults {
            random, bip39, length, words, symbols, separator,
        } => {
            // Decide target mode: explicit flag wins, else preserve current.
            let target_bip39 = if random { false }
                else if bip39 { true }
                else { matches!(settings.generator_defaults, GeneratorRequest::Bip39 { .. }) };

            // Pull existing fields where compatible, else seed with sensible
            // defaults (kept in sync with `GeneratorRequest::default()`).
            let (cur_length, cur_classes, cur_charset) = match &settings.generator_defaults {
                GeneratorRequest::Random { length, classes, symbol_charset } => {
                    (*length, *classes, symbol_charset.clone())
                }
                _ => (
                    20,
                    CharClasses { lower: true, upper: true, digits: true, symbols: true },
                    SymbolCharset::SafeOnly,
                ),
            };
            let (cur_words, cur_sep, cur_cap) = match &settings.generator_defaults {
                GeneratorRequest::Bip39 { word_count, separator, capitalization } => {
                    (*word_count, separator.clone(), *capitalization)
                }
                _ => (5, " ".to_string(), Capitalization::Lower),
            };

            settings.generator_defaults = if target_bip39 {
                GeneratorRequest::Bip39 {
                    word_count: words.unwrap_or(cur_words),
                    separator: separator.unwrap_or(cur_sep),
                    capitalization: cur_cap,
                }
            } else {
                let charset = match symbols.as_deref() {
                    None => cur_charset,
                    Some("safe") => SymbolCharset::SafeOnly,
                    Some("extended") => SymbolCharset::Extended,
                    Some(other) => SymbolCharset::Custom(other.to_string()),
                };
                GeneratorRequest::Random {
                    length: length.unwrap_or(cur_length),
                    classes: cur_classes,
                    symbol_charset: charset,
                }
            };
        }
    }

    vault.save_settings(&settings)?;
    commit_paths(&vault, "settings: update", &["settings.enc"])?;
    eprintln!("Settings updated.");
    Ok(())
}
fn cmd_sync() -> Result<()> {
    let root = crate::helpers::vault_dir()?;
    let pull = crate::helpers::git_command(&root, &["pull", "--rebase"]).status()?;
    if !pull.success() { anyhow::bail!("git pull --rebase failed"); }
    let push = crate::helpers::git_command(&root, &["push"]).status()?;
    if !push.success() { anyhow::bail!("git push failed"); }
    eprintln!("Sync complete.");
    Ok(())
}

fn cmd_status() -> Result<()> {
    let vault = crate::session::UnlockedVault::unlock_interactive()?;
    let root = vault.root().to_path_buf();
    let manifest = vault.load_manifest()?;

    let total_items = manifest.items.len();
    let trashed_items = manifest.items.values().filter(|e| e.trashed_at.is_some()).count();
    let active_items = total_items - trashed_items;

    let (attachment_count, attachment_bytes) = manifest.items.values()
        .flat_map(|e| e.attachment_summaries.iter())
        .fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size));

    let last_commit = crate::helpers::git_command(&root, &[
        "log", "-1", "--pretty=format:%h %s",
    ]).output()
        .ok()
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim().to_string())
        .unwrap_or_else(|| "(no commits)".into());

    // Last backup age (read from marker written by cmd_backup_export).
    let last_backup_path = vault.root().join(".relicario").join("last_backup");
    let last_backup_str = if last_backup_path.exists() {
        let line = std::fs::read_to_string(&last_backup_path)
            .unwrap_or_default()
            .trim()
            .to_string();
        // Parse the ISO-8601 we wrote in cmd_backup_export.
        match chrono::DateTime::parse_from_rfc3339(&line) {
            Ok(then) => {
                let now = relicario_core::now_unix();
                let age = now - then.timestamp();
                crate::helpers::humanize_age(age.max(0))
            }
            Err(_) => "unknown".to_string(),
        }
    } else {
        "never".to_string()
    };

    println!("Vault:    {}", root.display());
    println!("Items:    {total_items} total ({active_items} active, {trashed_items} trashed)");
    println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
    println!("Last commit: {last_commit}");
    println!("Last export: {last_backup_str}");
    Ok(())
}
#[derive(serde::Serialize)]
struct ParamsFile {
    format_version: u32,
    kdf: ParamsKdf,
    aead: String,
    salt_path: String,
}

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

fn cmd_rate(passphrase: String) -> Result<()> {
    let pw: String = if passphrase == "-" {
        use std::io::BufRead;
        let stdin = std::io::stdin();
        let mut line = String::new();
        stdin.lock().read_line(&mut line)?;
        line.trim_end_matches(&['\r', '\n'][..]).to_string()
    } else {
        passphrase
    };
    let est = relicario_core::generators::rate_passphrase(&pw);
    let label = match est.score {
        0 => "very weak",
        1 => "weak",
        2 => "fair",
        3 => "good",
        4 => "strong",
        _ => "?",
    };
    println!("score:    {}/4  ({})", est.score, label);
    println!("guesses:  ~10^{:.1}", est.guesses_log10);
    println!("note:     init requires score ≥ 3 (see `relicario init`)");
    Ok(())
}

// ── Device management ─────────────────────────────────────────────────────────

/// Build a `GiteaClient` from flags or environment variables.
fn load_gitea_client(
    gitea_url: Option<String>,
    gitea_token: Option<String>,
    owner: Option<String>,
    repo: Option<String>,
) -> Result<crate::gitea::GiteaClient> {
    let url = gitea_url
        .or_else(|| std::env::var("RELICARIO_GITEA_URL").ok())
        .ok_or_else(|| anyhow::anyhow!(
            "Gitea URL required — pass --gitea-url or set RELICARIO_GITEA_URL"
        ))?;
    let token = gitea_token
        .or_else(|| std::env::var("RELICARIO_GITEA_TOKEN").ok())
        .ok_or_else(|| anyhow::anyhow!(
            "Gitea token required — pass --gitea-token or set RELICARIO_GITEA_TOKEN"
        ))?;
    let owner = owner
        .or_else(|| std::env::var("RELICARIO_GITEA_OWNER").ok())
        .ok_or_else(|| anyhow::anyhow!(
            "Gitea owner required — pass --owner or set RELICARIO_GITEA_OWNER"
        ))?;
    let repo = repo
        .or_else(|| std::env::var("RELICARIO_GITEA_REPO").ok())
        .ok_or_else(|| anyhow::anyhow!(
            "Gitea repo required — pass --repo or set RELICARIO_GITEA_REPO"
        ))?;
    Ok(crate::gitea::GiteaClient::new(&url, &token, &owner, &repo))
}

fn cmd_device(action: DeviceAction) -> Result<()> {
    use std::fs;
    use relicario_core::device::{DeviceEntry, RevokedEntry, generate_keypair};

    let root = crate::helpers::vault_dir()?;
    let relicario_dir = root.join(".relicario");
    let devices_path = relicario_dir.join("devices.json");

    match action {
        DeviceAction::Add { name, gitea_url, gitea_token, owner, repo, no_gitea } => {
            // Guard: don't overwrite an already-registered device name.
            let existing: Vec<DeviceEntry> = fs::read(&devices_path)
                .ok()
                .and_then(|b| serde_json::from_slice(&b).ok())
                .unwrap_or_default();
            if existing.iter().any(|d| d.name == name) {
                anyhow::bail!("a device named '{}' is already registered", name);
            }

            eprintln!("Generating signing keypair...");
            let (signing_priv, signing_pub) = generate_keypair()
                .map_err(|e| anyhow::anyhow!("generate signing keypair: {e}"))?;

            eprintln!("Generating deploy keypair...");
            let (deploy_priv, deploy_pub) = generate_keypair()
                .map_err(|e| anyhow::anyhow!("generate deploy keypair: {e}"))?;

            // Optionally register deploy key with Gitea.
            let gitea_key_id: u64 = if no_gitea {
                eprintln!("Skipping Gitea deploy key registration (--no-gitea).");
                0
            } else {
                let client = load_gitea_client(gitea_url, gitea_token, owner, repo)?;
                let key_title = format!("relicario-{}", name);
                eprintln!("Registering deploy key '{}' with Gitea...", key_title);
                client.create_deploy_key(&key_title, &deploy_pub)?
            };

            // Store keys locally with proper permissions.
            crate::device::store_device_keys(
                &name,
                &signing_priv,
                &signing_pub,
                &deploy_priv,
                &deploy_pub,
                gitea_key_id,
            )?;

            // Mark as current device.
            crate::device::set_current_device(&name)?;

            // Configure git signing + SSH deploy key in the vault repo.
            crate::device::configure_git_signing(&root, &name)?;

            // Update devices.json.
            let current_name = name.clone();
            let mut devices = existing;
            devices.push(DeviceEntry {
                name: name.clone(),
                public_key: signing_pub.clone(),
                added_at: relicario_core::now_unix(),
                added_by: current_name,
            });
            fs::create_dir_all(&relicario_dir)?;
            fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;

            // Commit the update.
            let status = crate::helpers::git_command(
                &root,
                &["add", ".relicario/devices.json"],
            )
            .status()?;
            if !status.success() {
                anyhow::bail!("git add .relicario/devices.json failed");
            }
            let msg = format!("device: register {}", name);
            let status = crate::helpers::git_command(&root, &["commit", "-m", &msg])
                .status()?;
            if !status.success() {
                anyhow::bail!("git commit failed");
            }

            eprintln!("Device '{}' registered.", name);
            eprintln!("Signing public key:");
            eprintln!("  {}", signing_pub);
            if gitea_key_id != 0 {
                eprintln!("Gitea deploy key ID: {}", gitea_key_id);
            }
            Ok(())
        }

        DeviceAction::Revoke { name } => {
            // Guard: refuse to revoke the currently active device (would lock
            // the user out).  They must add another device first.
            if let Some(current) = crate::device::current_device()? {
                if current == name {
                    anyhow::bail!(
                        "cannot revoke the current device '{}' — you would lose \
                         push access. Register another device first.",
                        name
                    );
                }
            }

            // Load devices.json.
            let mut devices: Vec<DeviceEntry> = fs::read(&devices_path)
                .ok()
                .and_then(|b| serde_json::from_slice(&b).ok())
                .unwrap_or_default();

            let device = devices
                .iter()
                .find(|d| d.name == name)
                .ok_or_else(|| anyhow::anyhow!("device '{}' not found", name))?
                .clone();

            // Remove from devices.json.
            devices.retain(|d| d.name != name);
            fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;

            // Append to revoked.json.
            let revoked_path = relicario_dir.join("revoked.json");
            let mut revoked: Vec<RevokedEntry> = fs::read(&revoked_path)
                .ok()
                .and_then(|b| serde_json::from_slice(&b).ok())
                .unwrap_or_default();

            let revoked_by = crate::device::current_device()?
                .unwrap_or_else(|| "unknown".to_string());

            revoked.push(RevokedEntry {
                name: name.clone(),
                public_key: device.public_key.clone(),
                revoked_at: relicario_core::now_unix(),
                revoked_by,
            });
            fs::write(&revoked_path, serde_json::to_string_pretty(&revoked)?)?;

            // Delete deploy key from Gitea (best-effort — don't fail if it
            // was already deleted or the config is missing).
            if let Ok(key_id) = crate::device::load_gitea_key_id(&name) {
                if key_id != 0 {
                    // Build client from env vars only (no flags in revoke).
                    match load_gitea_client(None, None, None, None) {
                        Ok(client) => {
                            if let Err(e) = client.delete_deploy_key(key_id) {
                                eprintln!(
                                    "warning: failed to delete Gitea deploy key {}: {}",
                                    key_id, e
                                );
                            } else {
                                eprintln!("Deleted Gitea deploy key {}.", key_id);
                            }
                        }
                        Err(_) => {
                            eprintln!(
                                "warning: Gitea env vars not set — deploy key {} \
                                 not deleted from Gitea.",
                                key_id
                            );
                        }
                    }
                }
            }

            // Commit devices.json + revoked.json (always both — revoked.json
            // was just written above so it is guaranteed to exist).
            let add_args = [
                "add",
                ".relicario/devices.json",
                ".relicario/revoked.json",
            ];
            let status = crate::helpers::git_command(&root, &add_args).status()?;
            if !status.success() {
                anyhow::bail!("git add failed");
            }
            let msg = format!("device: revoke {}", name);
            let status = crate::helpers::git_command(&root, &["commit", "-m", &msg])
                .status()?;
            if !status.success() {
                anyhow::bail!("git commit failed");
            }

            eprintln!("Device '{}' revoked.", name);
            eprintln!("Revoked signing key: {}", device.public_key);
            Ok(())
        }

        DeviceAction::List => {
            let devices: Vec<DeviceEntry> = fs::read(&devices_path)
                .ok()
                .and_then(|b| serde_json::from_slice(&b).ok())
                .unwrap_or_default();

            let current = crate::device::current_device()?.unwrap_or_default();

            if devices.is_empty() {
                println!("No registered devices.");
                return Ok(());
            }

            println!("{:<20} {:<20} {}", "NAME", "ADDED", "SIGNING KEY (prefix)");
            println!("{}", "-".repeat(72));
            for d in &devices {
                let marker = if d.name == current { " *" } else { "" };
                let added = crate::helpers::iso8601(d.added_at);
                // Show only the first 40 chars of the public key line for readability.
                let key_prefix: String = d.public_key.chars().take(40).collect();
                println!("{:<20} {:<20} {}{}",
                    d.name, added, key_prefix, marker);
            }
            if !current.is_empty() {
                println!("\n* = current device");
            }
            Ok(())
        }
    }
}
