//! CLI-side helpers: vault dir detection, hardened git shell-out, ISO-8601
//! timestamp formatting. Kept in their own module so every command handler
//! stays terse.

use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{bail, Context, Result};
use chrono::DateTime;

/// Walk up from `start` looking for a directory containing `.relicario/`.
/// Returns the vault root (the directory that contains `.relicario/`).
/// Audit L8: refuses to operate outside an initialized vault.
pub fn find_vault_dir_from(start: &Path) -> Result<PathBuf> {
    let mut cur = start.to_path_buf();
    loop {
        if cur.join(".relicario").is_dir() {
            return Ok(cur);
        }
        if !cur.pop() {
            bail!(
                "no .relicario/ directory found in {} or any parent — \
                 run `relicario init` first",
                start.display()
            );
        }
    }
}

/// Convenience wrapper that starts the search from `std::env::current_dir()`.
pub fn vault_dir() -> Result<PathBuf> {
    let cwd = std::env::current_dir().context("failed to get current directory")?;
    find_vault_dir_from(&cwd)
}

/// Path to the `.relicario/` configuration directory within the vault.
#[allow(dead_code)]
pub fn relicario_dir() -> Result<PathBuf> {
    Ok(vault_dir()?.join(".relicario"))
}

/// Build a hardened `git` command — no hooks, no GPG signing, no editor.
/// Audit H4: prevents vault mutations from running hostile hooks, blocking on
/// GPG passphrase prompts (which would hold the master key alive), or entering
/// $EDITOR during rebase conflict markers.
pub fn git_command(repo: &Path, args: &[&str]) -> Command {
    let mut cmd = Command::new("git");
    cmd.current_dir(repo);
    cmd.args([
        "-c", "core.hooksPath=/dev/null",
        "-c", "commit.gpgsign=false",
        "-c", "core.editor=true",
    ]);
    cmd.args(args);
    cmd
}

/// Run `git <args>` in `repo` with the same hardening as `git_command`,
/// capturing stdout/stderr and reproducing them on failure so the caller
/// sees git's exact diagnostic instead of just a verb.
///
/// `context` should be a short caller-supplied label like `"commit add: <id>"`
/// or `"sync: git push"`; it prefixes the bail message so the failing call is
/// identifiable from the error alone.
///
/// Trade-off vs. `git_command(...).status()`: this captures the child's stderr
/// (so live progress disappears during long-running fetches/pushes) but the
/// captured chunk is replayed verbatim on failure. The win is that
/// non-interactive callers (tests, hooks, CI, redirected stdout) finally see
/// pre-receive rejections, signing-key prompts, and dirty-tree complaints
/// instead of one-line "git X failed" bails. Use `git_command` directly when
/// live streaming is required.
pub fn git_run(repo: &Path, args: &[&str], context: &str) -> Result<()> {
    let output = git_command(repo, args)
        .output()
        .with_context(|| format!("{context}: failed to spawn git"))?;
    if !output.status.success() {
        if !output.stdout.is_empty() {
            eprint!("{}", String::from_utf8_lossy(&output.stdout));
        }
        if !output.stderr.is_empty() {
            eprint!("{}", String::from_utf8_lossy(&output.stderr));
        }
        bail!("{context}: git failed ({})", output.status);
    }
    Ok(())
}

/// Stage `paths` for removal in one `git rm -rf --ignore-unmatch` invocation.
/// `--ignore-unmatch` is load-bearing: a previous partial-write crash can
/// leave the manifest entry without the corresponding `items/<id>.enc` on
/// disk, and we want the rm to succeed regardless.
pub fn git_rm(repo: &Path, paths: &[String], context: &str) -> Result<()> {
    let mut args: Vec<&str> = vec!["rm", "-rf", "--ignore-unmatch"];
    args.extend(paths.iter().map(String::as_str));
    git_run(repo, &args, context)
}

/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
/// a numeric string.
pub fn iso8601(unix_seconds: i64) -> String {
    DateTime::from_timestamp(unix_seconds, 0)
        .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
        .unwrap_or_else(|| format!("invalid-timestamp:{unix_seconds}"))
}

/// Format a duration (in seconds) as a coarse human-readable string:
/// "just now" / "5 minutes ago" / "4 days ago" / "3 months ago".
pub fn humanize_age(seconds: i64) -> String {
    if seconds < 60 { return "just now".to_string(); }
    if seconds < 3600 { return format!("{} minute{} ago", seconds / 60, plural(seconds / 60)); }
    if seconds < 86_400 { return format!("{} hour{} ago", seconds / 3600, plural(seconds / 3600)); }
    if seconds < 86_400 * 30 {
        let d = seconds / 86_400;
        return format!("{d} day{} ago", plural(d));
    }
    if seconds < 86_400 * 365 {
        let m = seconds / (86_400 * 30);
        return format!("{m} month{} ago", plural(m));
    }
    let y = seconds / (86_400 * 365);
    format!("{y} year{} ago", plural(y))
}

fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } }

/// Path to the plaintext `groups.cache` file used by shell completion to
/// enumerate `--group <TAB>` candidates without unlocking the vault.
///
/// **Plaintext leak:** group names land on disk in cleartext alongside the
/// vault directory. This is intentional — the file feeds shell completion,
/// which cannot prompt for a passphrase. In debug builds, set
/// `RELICARIO_NO_GROUPS_CACHE=1` to suppress the write.
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
    vault_dir.join(".relicario").join("groups.cache")
}

/// 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.
///
/// Visibility note: this is `pub(crate)` so only `session::after_manifest_change`
/// can call it. The Plan B Phase 4 done-criterion requires every mutating
/// handler to funnel through the wrapper — exposing this helper to commands/
/// would let a caller refresh the cache without updating the manifest, breaking
/// the invariant.
pub(crate) fn refresh_groups_cache(vault_dir: &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 _ = write_groups_cache(vault_dir, &set);
}

/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
/// suppresses the write (developer debugging tool). In release builds the env
/// var is ignored.
pub fn write_groups_cache(
    vault_dir: &Path,
    groups: &std::collections::BTreeSet<String>,
) -> std::io::Result<()> {
    if cfg!(debug_assertions) && std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
        return Ok(());
    }
    let path = groups_cache_path(vault_dir);
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let mut body = String::new();
    for g in groups {
        body.push_str(g);
        body.push('\n');
    }
    std::fs::write(path, body)
}

/// Sanitize a string for use in a git commit message subject line.
///
/// Removes all Unicode control characters (U+0000–U+001F, U+007F, and higher
/// control planes) so that newlines and escape sequences cannot corrupt `git
/// log` output. Truncates to 50 characters so the subject line stays within
/// the conventional limit.
///
/// Audit I1: item titles are user-supplied and may contain arbitrary bytes.
pub fn sanitize_for_commit(s: &str) -> String {
    s.chars()
        .filter(|c| !c.is_control())
        .take(50)
        .collect()
}

/// Decode a QR image at `path`. Returns the otpauth secret (base32) if the
/// QR decodes to an `otpauth://...` URI with a `secret` query param.
pub fn decode_totp_qr(path: &std::path::Path) -> anyhow::Result<String> {
    let img = image::open(path)
        .map_err(|e| anyhow::anyhow!("failed to read image: {e}"))?
        .to_luma8();
    let mut prepared = rqrr::PreparedImage::prepare(img);
    let grids = prepared.detect_grids();
    let grid = grids
        .into_iter()
        .next()
        .ok_or_else(|| anyhow::anyhow!("no QR code found in image"))?;
    let (_meta, content) = grid
        .decode()
        .map_err(|e| anyhow::anyhow!("QR decode failed: {e}"))?;
    if !content.starts_with("otpauth://") {
        return Err(anyhow::anyhow!("not a TOTP URI (expected otpauth://...)"));
    }
    let parsed =
        url::Url::parse(&content).map_err(|e| anyhow::anyhow!("invalid otpauth URI: {e}"))?;
    let secret = parsed
        .query_pairs()
        .find(|(k, _)| k == "secret")
        .map(|(_, v)| v.to_string())
        .ok_or_else(|| anyhow::anyhow!("otpauth URI missing `secret` parameter"))?;
    Ok(secret)
}

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

    #[test]
    fn vault_dir_finds_marker_in_cwd() {
        let tmp = TempDir::new().unwrap();
        std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
        let found = find_vault_dir_from(tmp.path()).unwrap();
        assert_eq!(found, tmp.path());
    }

    #[test]
    fn vault_dir_finds_marker_in_parent() {
        let tmp = TempDir::new().unwrap();
        std::fs::create_dir(tmp.path().join(".relicario")).unwrap();
        let subdir = tmp.path().join("sub/nested");
        std::fs::create_dir_all(&subdir).unwrap();
        let found = find_vault_dir_from(&subdir).unwrap();
        assert_eq!(found, tmp.path());
    }

    #[test]
    fn vault_dir_errors_when_missing() {
        let tmp = TempDir::new().unwrap();
        let err = find_vault_dir_from(tmp.path()).unwrap_err();
        assert!(err.to_string().contains(".relicario"));
    }

    #[test]
    fn iso8601_formats_fixed_timestamp() {
        // 2026-04-19T00:00:00Z = 1776556800
        assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z");
    }

    #[test]
    fn sanitize_for_commit_strips_control_chars() {
        assert_eq!(sanitize_for_commit("line1\nline2"), "line1line2");
        assert_eq!(sanitize_for_commit("a\tb"), "ab");
        assert_eq!(sanitize_for_commit("normal"), "normal");
        assert_eq!(sanitize_for_commit("cr\r\nline"), "crline");
        // ESC (U+001B) is control and gets stripped; bracket sequences are printable
        assert_eq!(sanitize_for_commit("\x1b[31mred\x1b[0m"), "[31mred[0m");
    }

    #[test]
    fn sanitize_for_commit_truncates_to_50() {
        let long = "a".repeat(60);
        assert_eq!(sanitize_for_commit(&long).len(), 50);
        assert_eq!(sanitize_for_commit(&long), "a".repeat(50));
    }

    #[test]
    fn sanitize_for_commit_allows_unicode() {
        assert_eq!(sanitize_for_commit("cafe\u{0301}"), "cafe\u{0301}");
        assert_eq!(sanitize_for_commit("emoji \u{1F4AA}"), "emoji \u{1F4AA}");
    }

    #[test]
    fn git_run_bails_with_context_on_failure() {
        // Empty tempdir — `git status` will fail with "not a git repository".
        let tmp = TempDir::new().unwrap();
        let err = git_run(tmp.path(), &["status"], "test_ctx").unwrap_err();
        let msg = format!("{err}");
        assert!(msg.contains("test_ctx"), "context not in error: {msg}");
        assert!(msg.contains("git failed"), "missing failure marker: {msg}");
    }

    #[test]
    fn git_run_succeeds_for_a_zero_exit_command() {
        // `git --version` always succeeds and is independent of cwd.
        let tmp = TempDir::new().unwrap();
        git_run(tmp.path(), &["--version"], "version probe")
            .expect("git --version should succeed");
    }

    #[test]
    fn humanize_age_buckets() {
        assert_eq!(humanize_age(0), "just now");
        assert_eq!(humanize_age(59), "just now");
        assert_eq!(humanize_age(60), "1 minute ago");
        assert_eq!(humanize_age(120), "2 minutes ago");
        assert_eq!(humanize_age(3_599), "59 minutes ago");
        assert_eq!(humanize_age(3_600), "1 hour ago");
        assert_eq!(humanize_age(7_200), "2 hours ago");
        assert_eq!(humanize_age(86_400), "1 day ago");
        assert_eq!(humanize_age(86_400 * 2), "2 days ago");
        assert_eq!(humanize_age(86_400 * 30), "1 month ago");
        assert_eq!(humanize_age(86_400 * 60), "2 months ago");
        assert_eq!(humanize_age(86_400 * 365), "1 year ago");
        assert_eq!(humanize_age(86_400 * 365 * 3), "3 years ago");
    }
}
