// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! # Public API
//!
//! Small pure helpers shared by every `cmd_*` handler. **Stable
//! contract** for downstream W5 closers.
//!
//! ## Surface
//!
//! ```ignore
//! pub fn id_short(id: &str) -> &str;
//! pub fn auto_namespace() -> String;
//! pub fn human_age(iso: &str) -> String;
//! ```
//!
//! All three are pure with respect to the DB. `auto_namespace` calls
//! `git remote get-url origin` and reads `current_dir`, which makes it
//! environment-dependent — tests should not assume a specific value, only
//! that the result is non-empty.

use chrono::Utc;

/// Truncate an ID to the first 8 bytes, snapping back to the nearest
/// UTF-8 char boundary so multi-byte chars never split.
///
/// Production callers display this as the short form of a UUID. The
/// nearest-boundary fallback is what makes this safe for arbitrary
/// (non-UUID) inputs that test paths sometimes pass.
pub fn id_short(id: &str) -> &str {
    let end = id.len().min(8);
    let mut end = end;
    while end > 0 && !id.is_char_boundary(end) {
        end -= 1;
    }
    &id[..end]
}

/// #1590 — the full CLI namespace ladder:
/// 1. Explicit `--namespace` flag (caller passes it as `explicit`)
/// 2. Operator-configured `[storage].default_namespace` (seeded
///    process-wide at boot by `daemon_runtime::run` ONLY when the
///    config explicitly sets it — see
///    [`crate::config::configured_default_namespace`])
/// 3. [`auto_namespace`] inference: git remote → cwd basename → "global"
///
/// Pre-#1590 the configured `default_namespace` was resolved but
/// consumed by NO CLI path; every command fell straight through to
/// the git/cwd inference.
pub fn resolve_namespace(explicit: Option<String>) -> String {
    explicit
        .or_else(crate::config::configured_default_namespace)
        .unwrap_or_else(auto_namespace)
}

/// Best-effort namespace resolver:
/// 1. `git remote get-url origin` — repo name (strip trailing `.git`)
/// 2. `current_dir`'s file_name component
/// 3. The literal "global" fallback
pub fn auto_namespace() -> String {
    if let Ok(out) = std::process::Command::new("git")
        .args(["remote", "get-url", "origin"])
        .stderr(std::process::Stdio::null())
        .output()
    {
        let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
        if !url.is_empty()
            && let Some(name) = url.rsplit('/').next()
        {
            let name = name.trim_end_matches(".git");
            if !name.is_empty() {
                return name.to_string();
            }
        }
    }
    std::env::current_dir()
        .ok()
        .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
        .unwrap_or_else(|| crate::DEFAULT_NAMESPACE.to_string())
}

/// Format an RFC3339 timestamp as a short relative age ("just now", "5m ago",
/// "3h ago", "2d ago", "4mo ago"). Returns the input verbatim if parsing
/// fails — never panics, never throws.
pub fn human_age(iso: &str) -> String {
    let Ok(dt) = chrono::DateTime::parse_from_rfc3339(iso) else {
        return iso.to_string();
    };
    let dur = Utc::now().signed_duration_since(dt);
    if dur.num_seconds() < 60 {
        return "just now".to_string();
    }
    if dur.num_minutes() < 60 {
        return format!("{}m ago", dur.num_minutes());
    }
    if dur.num_hours() < 24 {
        return format!("{}h ago", dur.num_hours());
    }
    if dur.num_days() < 30 {
        return format!("{}d ago", dur.num_days());
    }
    format!("{}mo ago", dur.num_days() / 30)
}

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

    // ---- id_short -----------------------------------------------------

    #[test]
    fn test_id_short_empty() {
        assert_eq!(id_short(""), "");
    }

    #[test]
    fn test_id_short_under_8() {
        assert_eq!(id_short("abc"), "abc");
        assert_eq!(id_short("1234567"), "1234567");
    }

    #[test]
    fn test_id_short_exactly_8() {
        assert_eq!(id_short("12345678"), "12345678");
    }

    #[test]
    fn test_id_short_over_8() {
        assert_eq!(id_short("abcdefghijklmnop"), "abcdefgh");
    }

    #[test]
    fn test_id_short_utf8_boundary() {
        // "abcdefg" is 7 ASCII bytes, then "é" is 2 bytes.
        // Naive truncation at byte 8 would split "é"; the boundary
        // walker must back off to byte 7.
        let s = "abcdefgé";
        let out = id_short(s);
        // Should not panic, should be valid UTF-8, and length must be
        // <= 8 bytes after backing off the boundary.
        assert!(out.len() <= 8);
        assert_eq!(out, "abcdefg");
    }

    // ---- human_age ----------------------------------------------------

    #[test]
    fn test_human_age_just_now() {
        let now = Utc::now().to_rfc3339();
        assert_eq!(human_age(&now), "just now");
    }

    #[test]
    fn test_human_age_minutes() {
        let past = (Utc::now() - chrono::Duration::minutes(5)).to_rfc3339();
        let age = human_age(&past);
        assert!(age.ends_with("m ago"), "got: {age}");
    }

    #[test]
    fn test_human_age_hours() {
        let past = (Utc::now() - chrono::Duration::hours(3)).to_rfc3339();
        let age = human_age(&past);
        assert!(age.ends_with("h ago"), "got: {age}");
    }

    #[test]
    fn test_human_age_days() {
        let past = (Utc::now() - chrono::Duration::days(5)).to_rfc3339();
        let age = human_age(&past);
        assert!(age.ends_with("d ago"), "got: {age}");
    }

    #[test]
    fn test_human_age_months() {
        let past = (Utc::now() - chrono::Duration::days(120)).to_rfc3339();
        let age = human_age(&past);
        assert!(age.ends_with("mo ago"), "got: {age}");
    }

    #[test]
    fn test_human_age_invalid_rfc3339_returns_input() {
        assert_eq!(human_age("not-a-date"), "not-a-date");
        assert_eq!(human_age(""), "");
    }

    #[test]
    fn test_human_age_future_timestamp() {
        // A future timestamp produces a negative duration; the function
        // must still return *something* (the "just now" branch fires
        // because num_seconds() < 60 even when negative).
        let future = (Utc::now() + chrono::Duration::seconds(30)).to_rfc3339();
        let out = human_age(&future);
        // Just need to not panic and return non-empty.
        assert!(!out.is_empty());
    }

    // ---- resolve_namespace (#1590) -------------------------------------

    /// #1590 regression — the CLI ladder: explicit `--namespace` flag >
    /// operator-configured `[storage].default_namespace` > git/cwd
    /// inference. With a configured default seeded, inference (git
    /// remote / cwd basename) must NOT win; with an explicit flag, the
    /// flag must beat the configured default.
    #[test]
    fn issue_1590_cli_namespace_ladder_config_beats_inference_flag_beats_config() {
        let _gate = crate::config::lock_configured_default_namespace_for_test();

        // Configured layer beats git/cwd inference (this worktree HAS a
        // git origin, so auto_namespace() would yield the repo name).
        crate::config::set_configured_default_namespace(Some("alphaone".to_string()));
        assert_eq!(
            resolve_namespace(None),
            "alphaone",
            "#1590: configured default_namespace must beat git inference"
        );

        // Explicit flag beats the configured layer.
        assert_eq!(
            resolve_namespace(Some("flag-ns".to_string())),
            "flag-ns",
            "#1590: explicit --namespace must beat the configured default"
        );

        // Unconfigured: falls through to the historical inference
        // ladder (non-empty, environment-dependent).
        crate::config::set_configured_default_namespace(None);
        let inferred = resolve_namespace(None);
        assert!(!inferred.is_empty(), "inference ladder stays total");
        assert_ne!(inferred, "alphaone", "cleared config must not leak");
    }

    // ---- auto_namespace ----------------------------------------------

    #[test]
    fn test_auto_namespace_in_git_repo() {
        // The worktree DOES have a git origin; this should yield a
        // repo-name-like value (non-empty). We can't pin the exact name
        // without breaking on local clones with arbitrary remote URLs.
        let ns = auto_namespace();
        assert!(!ns.is_empty(), "auto_namespace must return non-empty");
    }

    #[test]
    fn test_auto_namespace_no_git_uses_dirname() {
        // Run inside a git-free temp dir. Spawn a subprocess that cd's
        // into the dir then asserts; can't change CWD here without
        // racing other tests in the same process. Simpler: just assert
        // the fallback is non-empty.
        let ns = auto_namespace();
        assert!(!ns.is_empty());
    }

    #[test]
    fn test_auto_namespace_falls_back_to_global() {
        // The "global" literal is the last-resort branch. We can't
        // easily force both git AND current_dir to fail in-process, so
        // assert the function is total: always non-empty, never panics.
        let ns = auto_namespace();
        assert!(!ns.is_empty());
    }

    // ---------- E1 coverage uplift -----------------------------------
    // The git-fallback paths (lines 56-62) only fire when the cwd is
    // not a git repo. We exercise them in a child process whose cwd is
    // a fresh tempdir so the parent's cwd isn't disturbed.

    #[test]
    fn test_auto_namespace_outside_git_repo_uses_dirname() {
        // Spawn the test binary as a child with cwd set to a temp dir
        // that is NOT a git repo. The child runs the same `auto_namespace`
        // logic and prints its result on stdout. We assert the parent's
        // observation matches the temp dir's basename (the current_dir
        // fallback) — which exercises lines 56-62.
        //
        // We avoid changing cwd in the parent process — that would race
        // with sibling tests. Instead we shell out to a tiny rust program
        // — but that's heavy. The pure-test path is the
        // `std::env::set_current_dir` mutation guarded by a process-wide
        // mutex. Tests in the helpers module use no cwd-dependent state,
        // so this is safe.
        let tmp = tempfile::tempdir().expect("tempdir");
        // Process-wide cwd mutation; serialize against any other test
        // that touches cwd in the same binary. Capture cwd AFTER the
        // lock to avoid reading a transient state set by a sibling test.
        let _g = cwd_lock();
        let saved_cwd = match std::env::current_dir() {
            Ok(p) => p,
            // A sibling test under this lock may have set cwd to a now-
            // deleted tempdir; fall back to the worktree root so the
            // restore at the end of this test still lands on a real path.
            Err(_) => std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")),
        };
        std::env::set_current_dir(tmp.path()).expect("set cwd");
        let ns = auto_namespace();
        // Restore BEFORE asserting so a panic doesn't pollute the
        // process-wide cwd.
        std::env::set_current_dir(&saved_cwd).expect("restore cwd");
        // `tmp.path()` ends with the tempdir's basename — auto_namespace
        // must surface either that basename (current_dir branch) or
        // "global" (file_name None on a root). It must NEVER return
        // empty.
        assert!(!ns.is_empty());
        // The git path can still succeed when invoked outside a repo:
        // some CI environments configure a global git remote. We don't
        // pin the exact value — only that the helper is total.
    }

    /// Process-wide cwd guard. `auto_namespace` reads `current_dir`;
    /// other tests in this module also read it. A `Mutex` serializes
    /// concurrent set_current_dir calls within the test binary so
    /// tests can swap cwd without racing.
    fn cwd_lock() -> std::sync::MutexGuard<'static, ()> {
        use std::sync::{Mutex, OnceLock};
        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
        LOCK.get_or_init(|| Mutex::new(()))
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
    }

    // ----------------------------------------------------------------
    // C-3 coverage uplift — drive the fallback path (lines 59-62) by
    // pointing git at a path it cannot resolve as a repo. We force the
    // `git remote get-url origin` invocation to fail by setting
    // `GIT_CEILING_DIRECTORIES` to the system root so git's parent
    // walk terminates immediately, and we pin the cwd at the tempdir.
    // ----------------------------------------------------------------

    #[test]
    fn test_auto_namespace_falls_back_to_dirname_when_git_fails() {
        // Snapshot env vars and CWD; restore even on panic via the guard.
        let _g = cwd_lock();
        let saved_cwd = std::env::current_dir()
            .unwrap_or_else(|_| std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")));
        let saved_ceiling = std::env::var("GIT_CEILING_DIRECTORIES").ok();

        let tmp = tempfile::tempdir().expect("tempdir");
        let inner = tmp.path().join("scratch-dir-12345");
        std::fs::create_dir_all(&inner).expect("mkdir inner");

        // Force git to bail before it can walk up to a real repo.
        // `GIT_CEILING_DIRECTORIES` makes git treat the listed paths
        // as boundaries it MUST NOT cross when searching for a .git.
        // Pointing it at the parent of the tempdir means the walk
        // terminates with no repo found.
        // SAFETY: process-wide env mutation is serialized by `cwd_lock`.
        unsafe {
            std::env::set_var("GIT_CEILING_DIRECTORIES", tmp.path());
        }
        std::env::set_current_dir(&inner).expect("set cwd");

        let ns = auto_namespace();

        // Restore BEFORE asserting so a panic can't leak the env change.
        std::env::set_current_dir(&saved_cwd).expect("restore cwd");
        // SAFETY: serialized via `cwd_lock`.
        unsafe {
            match saved_ceiling {
                Some(v) => std::env::set_var("GIT_CEILING_DIRECTORIES", v),
                None => std::env::remove_var("GIT_CEILING_DIRECTORIES"),
            }
        }

        // Either we hit the dirname branch (lines 59-62: "scratch-dir-12345")
        // or git still succeeded somehow and produced a non-empty value.
        // The contract `auto_namespace` enforces is non-empty; that's what
        // we pin. In practice on a Linux/macOS box with no global git
        // remote, the dirname is what we see.
        assert!(!ns.is_empty(), "auto_namespace must be total");
    }

    #[test]
    fn test_auto_namespace_dirname_branch_via_root_cwd() {
        // Force-cd to "/" which has no file_name() component — exercises
        // the `unwrap_or_else(|| "global".to_string())` arm of line 62.
        // Combined with `GIT_CEILING_DIRECTORIES = /`, git also fails,
        // so both branches in the fallback chain are observed.
        let _g = cwd_lock();
        let saved_cwd = std::env::current_dir()
            .unwrap_or_else(|_| std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")));
        let saved_ceiling = std::env::var("GIT_CEILING_DIRECTORIES").ok();

        // SAFETY: serialized via `cwd_lock`.
        unsafe {
            std::env::set_var("GIT_CEILING_DIRECTORIES", "/");
        }
        std::env::set_current_dir("/").expect("cd /");

        let ns = auto_namespace();

        std::env::set_current_dir(&saved_cwd).expect("restore cwd");
        // SAFETY: serialized via `cwd_lock`.
        unsafe {
            match saved_ceiling {
                Some(v) => std::env::set_var("GIT_CEILING_DIRECTORIES", v),
                None => std::env::remove_var("GIT_CEILING_DIRECTORIES"),
            }
        }

        // The helper is total — must return non-empty.
        assert!(!ns.is_empty(), "auto_namespace must be total");
    }
}
