//! Remote-tracking freshness primitives.
//!
//! Tracks how stale each repo's `origin/*` refs are by reading the mtime
//! of the `FETCH_HEAD` file under the repo's `.git` (plain) or `.bare`
//! (worktree-managed) directory. Provides:
//!
//! - [`fetched_at`]: locate `FETCH_HEAD` and read its mtime
//! - [`Freshness`]: coarse bucket the UI uses for color
//! - [`Thresholds`]: configurable boundaries between buckets
//! - [`classify`]: bucket an age against the thresholds
//! - [`render_age`]: human-readable relative-time string for a row
//! - [`parse_duration`]: tiny TOML duration parser ("5m", "1h", "2d")
//!
//! Design note: `FETCH_HEAD` is per-repo, not per-worktree. A bare clone
//! has exactly one, shared by every worktree attached to it. That is
//! exactly the right granularity for limb because fetching is a
//! repo-level operation.

use std::path::Path;
use std::time::{Duration, SystemTime};

use anyhow::{Result, bail};

/// Returns the mtime of `FETCH_HEAD` under `repo_anchor`, or `None` if
/// nothing has ever been fetched.
///
/// `repo_anchor` is the path returned by [`crate::discover::anchor_for`]:
/// the `.bare/` directory for a bare-clone layout, or the worktree
/// directory itself for a plain clone (where `.git/` lives at the same
/// level as the working tree).
#[must_use]
pub fn fetched_at(repo_anchor: &Path) -> Option<SystemTime> {
    let direct = repo_anchor.join("FETCH_HEAD");
    if let Ok(meta) = direct.metadata()
        && let Ok(mtime) = meta.modified()
    {
        return Some(mtime);
    }
    let nested = repo_anchor.join(".git").join("FETCH_HEAD");
    nested.metadata().and_then(|m| m.modified()).ok()
}

/// Coarse bucket for an age, used to drive color in the UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Freshness {
    /// Younger than [`Thresholds::fresh`].
    Fresh,
    /// Between [`Thresholds::fresh`] and [`Thresholds::warn`].
    Warn,
    /// Between [`Thresholds::warn`] and [`Thresholds::stale`].
    Stale,
    /// Older than [`Thresholds::stale`].
    Ancient,
    /// Never fetched (no `FETCH_HEAD` on disk).
    Unknown,
}

/// Resolved freshness thresholds.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Thresholds {
    /// Anything younger than this is [`Freshness::Fresh`].
    pub fresh: Duration,
    /// Anything younger than this and not fresh is [`Freshness::Warn`].
    pub warn: Duration,
    /// Anything younger than this and not fresh/warn is
    /// [`Freshness::Stale`]; older is [`Freshness::Ancient`].
    pub stale: Duration,
}

impl Default for Thresholds {
    fn default() -> Self {
        Self {
            fresh: Duration::from_secs(5 * 60),
            warn: Duration::from_secs(30 * 60),
            stale: Duration::from_secs(24 * 60 * 60),
        }
    }
}

/// Buckets `age` against `t`.
///
/// A `None` age means there is no `FETCH_HEAD`, which is reported as
/// [`Freshness::Unknown`].
#[must_use]
pub fn classify(age: Option<Duration>, t: &Thresholds) -> Freshness {
    match age {
        None => Freshness::Unknown,
        Some(d) if d < t.fresh => Freshness::Fresh,
        Some(d) if d < t.warn => Freshness::Warn,
        Some(d) if d < t.stale => Freshness::Stale,
        Some(_) => Freshness::Ancient,
    }
}

/// Renders an age relative to `now` as a short human-readable string,
/// e.g. `"3s"`, `"5m"`, `"2h"`, `"4d"`. Returns `"-"` if `fetched` is
/// `None`.
#[must_use]
pub fn render_age(now: SystemTime, fetched: Option<SystemTime>) -> String {
    let Some(t) = fetched else {
        return "-".into();
    };
    let dur = now.duration_since(t).unwrap_or_default();
    render_duration_short(dur)
}

/// Renders an age relative to `now` as a longer human-readable phrase,
/// e.g. `"3 seconds"`, `"5 minutes"`, `"2 hours"`, `"4 days"`. Used by
/// `limb doctor` where a sentence reads better than a column.
#[must_use]
pub fn render_age_long(dur: Duration) -> String {
    let secs = dur.as_secs();
    if secs < 60 {
        return format!("{secs} seconds");
    }
    if secs < 60 * 60 {
        return format!("{} minutes", secs / 60);
    }
    if secs < 24 * 60 * 60 {
        return format!("{} hours", secs / 3600);
    }
    format!("{} days", secs / (24 * 3600))
}

fn render_duration_short(d: Duration) -> String {
    let s = d.as_secs();
    if s < 60 {
        return format!("{s}s");
    }
    if s < 60 * 60 {
        return format!("{}m", s / 60);
    }
    if s < 24 * 60 * 60 {
        return format!("{}h", s / 3600);
    }
    format!("{}d", s / (24 * 3600))
}

/// Parses a short duration like `"5m"`, `"1h"`, `"2d"`, `"45s"`.
///
/// Suffixes: `s` (seconds), `m` (minutes), `h` (hours), `d` (days). The
/// numeric part must be a non-negative integer.
///
/// # Errors
///
/// Returns an error if the input is empty, lacks a recognised suffix,
/// has a non-integer numeric part, or overflows a u64 in seconds.
pub fn parse_duration(s: &str) -> Result<Duration> {
    let s = s.trim();
    if s.is_empty() {
        bail!("empty duration");
    }
    let (num_part, suffix) = s.split_at(s.len() - 1);
    let n: u64 = num_part
        .parse()
        .map_err(|_| anyhow::anyhow!("invalid duration '{s}'; expected e.g. '5m', '2h', '24h'"))?;
    let secs = match suffix {
        "s" => n,
        "m" => n.checked_mul(60).ok_or_else(|| overflow(s))?,
        "h" => n.checked_mul(60 * 60).ok_or_else(|| overflow(s))?,
        "d" => n.checked_mul(24 * 60 * 60).ok_or_else(|| overflow(s))?,
        _ => bail!("invalid duration suffix in '{s}'; expected one of s, m, h, d"),
    };
    Ok(Duration::from_secs(secs))
}

fn overflow(s: &str) -> anyhow::Error {
    anyhow::anyhow!("duration overflow in '{s}'")
}

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

    fn tmp() -> TempDir {
        tempfile::tempdir().expect("tempdir")
    }

    #[test]
    fn fetched_at_missing_returns_none() {
        let d = tmp();
        assert!(fetched_at(d.path()).is_none());
    }

    #[test]
    fn fetched_at_reads_direct_fetch_head() {
        let d = tmp();
        std::fs::write(d.path().join("FETCH_HEAD"), "").unwrap();
        assert!(fetched_at(d.path()).is_some());
    }

    #[test]
    fn fetched_at_reads_nested_fetch_head_under_dot_git() {
        let d = tmp();
        std::fs::create_dir(d.path().join(".git")).unwrap();
        std::fs::write(d.path().join(".git").join("FETCH_HEAD"), "").unwrap();
        assert!(fetched_at(d.path()).is_some());
    }

    #[test]
    fn classify_buckets() {
        let t = Thresholds::default();
        assert_eq!(classify(None, &t), Freshness::Unknown);
        assert_eq!(
            classify(Some(Duration::from_secs(60)), &t),
            Freshness::Fresh
        );
        assert_eq!(
            classify(Some(Duration::from_secs(5 * 60)), &t),
            Freshness::Warn
        );
        assert_eq!(
            classify(Some(Duration::from_secs(15 * 60)), &t),
            Freshness::Warn
        );
        assert_eq!(
            classify(Some(Duration::from_secs(30 * 60)), &t),
            Freshness::Stale
        );
        assert_eq!(
            classify(Some(Duration::from_secs(60 * 60)), &t),
            Freshness::Stale
        );
        assert_eq!(
            classify(Some(Duration::from_secs(24 * 60 * 60)), &t),
            Freshness::Ancient
        );
        assert_eq!(
            classify(Some(Duration::from_secs(7 * 24 * 60 * 60)), &t),
            Freshness::Ancient
        );
    }

    #[test]
    fn render_duration_short_boundaries() {
        assert_eq!(render_duration_short(Duration::from_secs(0)), "0s");
        assert_eq!(render_duration_short(Duration::from_secs(59)), "59s");
        assert_eq!(render_duration_short(Duration::from_secs(60)), "1m");
        assert_eq!(render_duration_short(Duration::from_secs(3599)), "59m");
        assert_eq!(render_duration_short(Duration::from_secs(3600)), "1h");
        assert_eq!(render_duration_short(Duration::from_secs(86_399)), "23h");
        assert_eq!(render_duration_short(Duration::from_secs(86_400)), "1d");
        assert_eq!(render_duration_short(Duration::from_secs(86_400 * 7)), "7d");
    }

    #[test]
    fn render_age_dash_when_none() {
        assert_eq!(render_age(SystemTime::now(), None), "-");
    }

    #[test]
    fn parse_duration_seconds() {
        assert_eq!(parse_duration("0s").unwrap(), Duration::from_secs(0));
        assert_eq!(parse_duration("45s").unwrap(), Duration::from_secs(45));
    }

    #[test]
    fn parse_duration_minutes_hours_days() {
        assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
        assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
        assert_eq!(parse_duration("2d").unwrap(), Duration::from_secs(172_800));
    }

    #[test]
    fn parse_duration_trims() {
        assert_eq!(parse_duration("  5m  ").unwrap(), Duration::from_secs(300));
    }

    #[test]
    fn parse_duration_rejects_invalid() {
        assert!(parse_duration("").is_err());
        assert!(parse_duration("5").is_err());
        assert!(parse_duration("abc").is_err());
        assert!(parse_duration("-1m").is_err());
        assert!(parse_duration("5x").is_err());
    }
}
