//! Small display formatters shared across tabs.

/// Human-readable byte size: `999 B`, `1.2 KB`, `4.7 MB`, `1.3 GB`.
pub fn human_size(bytes: u64) -> String {
    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
    let mut value = bytes as f64;
    let mut unit = 0;
    while value >= 1000.0 && unit < UNITS.len() - 1 {
        value /= 1000.0;
        unit += 1;
    }
    if unit == 0 {
        format!("{} B", bytes)
    } else {
        format!("{:.1} {}", value, UNITS[unit])
    }
}

/// `YYYY-MM-DD HH:MM` in local time; raw seconds if out of range.
pub fn fmt_mtime(unix_secs: i64) -> String {
    use chrono::TimeZone;
    match chrono::Local.timestamp_opt(unix_secs, 0) {
        chrono::LocalResult::Single(dt) => dt.format("%Y-%m-%d %H:%M").to_string(),
        _ => unix_secs.to_string(),
    }
}

/// Relative time for recent events, absolute for old ones: "just now",
/// "5 min ago", "3 h ago", else `YYYY-MM-DD HH:MM`.
pub fn fmt_ago(unix_secs: u64) -> String {
    let age = quicksearch_core::log::now_unix().saturating_sub(unix_secs);
    if age < 60 {
        "just now".to_string()
    } else if age < 3600 {
        format!("{} min ago", age / 60)
    } else if age < 86_400 {
        format!("{} h ago", age / 3600)
    } else {
        fmt_mtime(unix_secs as i64)
    }
}

/// A configured interval as a phrase to drop after "every": `90 min`,
/// `24 h`, `3 days`.
pub fn fmt_interval(minutes: u64) -> String {
    // The same clamp the scheduler applies. Zero is only reachable from a
    // hand-edited config — the spinner's floor is 5 — and it used to read
    // "run", from a time when the scheduler took it as always-due. It no
    // longer does (`coordinator::inner::periodic_due` raises it to one
    // minute, because "reindex continuously" is not a setting anyone wants
    // and manual mode is how you say "never"), so saying "run" here would
    // describe a behaviour the app does not have.
    let minutes = minutes.max(1);
    if minutes < 60 {
        return format!("{} min", minutes);
    }
    if minutes.is_multiple_of(1440) {
        let days = minutes / 1440;
        return if days == 1 {
            // "24 h", not "1 day": a staleness window reads better in hours.
            "24 h".to_string()
        } else {
            format!("{} days", days)
        };
    }
    if minutes.is_multiple_of(60) {
        return format!("{} h", minutes / 60);
    }
    format!("{} h {} min", minutes / 60, minutes % 60)
}

/// Group thousands for counts: `1,234,567`.
pub fn group_thousands(n: u64) -> String {
    let digits = n.to_string();
    let mut out = String::with_capacity(digits.len() + digits.len() / 3);
    for (i, c) in digits.chars().enumerate() {
        if i > 0 && (digits.len() - i).is_multiple_of(3) {
            out.push(',');
        }
        out.push(c);
    }
    out
}

/// Files/sec display. Never renders a nonzero rate as "0.0": slow rates
/// switch to a per-minute figure.
pub fn fmt_rate(files_per_sec: f64) -> String {
    if files_per_sec <= 0.0 {
        "0 files/s".to_string()
    } else if files_per_sec >= 10.0 {
        format!("{:.0} files/s", files_per_sec)
    } else if files_per_sec >= 1.0 {
        format!("{:.1} files/s", files_per_sec)
    } else {
        format!("{:.0} files/min", (files_per_sec * 60.0).max(1.0))
    }
}

/// Search duration: milliseconds under a second, seconds above.
pub fn fmt_elapsed(d: std::time::Duration) -> String {
    let ms = d.as_millis();
    if ms >= 1000 {
        format!("{:.1} s", d.as_secs_f64())
    } else {
        format!("{} ms", ms)
    }
}

/// A running clock: `0:07`, `4:32`, `1:04:12`. Seconds are always two
/// digits so the text does not change width every tick.
pub fn fmt_duration_clock(d: std::time::Duration) -> String {
    let secs = d.as_secs();
    let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
    if h > 0 {
        format!("{}:{:02}:{:02}", h, m, s)
    } else {
        format!("{}:{:02}", m, s)
    }
}

/// What a finished configuration reconciliation did, in one line. A clause
/// whose count is zero is left out rather than printed as "0", and a pass
/// that found nothing to change still reports that it ran.
pub fn fmt_reconcile_summary(deleted: usize, recontented: usize) -> String {
    let entries = |n: usize| {
        format!(
            "{} {}",
            group_thousands(n as u64),
            if n == 1 { "entry" } else { "entries" }
        )
    };
    let mut parts: Vec<String> = Vec::new();
    if deleted > 0 {
        parts.push(format!("{} removed", entries(deleted)));
    }
    if recontented > 0 {
        parts.push(format!("{} re-examined", entries(recontented)));
    }
    if parts.is_empty() {
        return "Configuration change applied".to_string();
    }
    format!("Configuration change applied · {}", parts.join(" · "))
}

/// Middle-truncate a path to at most `max_chars` characters. Char-count
/// based and `Ui`-free; rows with a real pixel budget use the width-aware
/// `ui_util::middle_elide` instead.
pub fn middle_truncate(s: &str, max_chars: usize) -> String {
    let chars: Vec<char> = s.chars().collect();
    if chars.len() <= max_chars || max_chars < 5 {
        return s.to_string();
    }
    let keep = max_chars - 1;
    let head = keep / 2;
    let tail = keep - head;
    let mut out: String = chars[..head].iter().collect();
    out.push('…');
    out.extend(&chars[chars.len() - tail..]);
    out
}

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

    #[test]
    fn sizes() {
        assert_eq!(human_size(0), "0 B");
        assert_eq!(human_size(999), "999 B");
        assert_eq!(human_size(1200), "1.2 KB");
        assert_eq!(human_size(4_700_000), "4.7 MB");
        assert_eq!(human_size(1_300_000_000), "1.3 GB");
    }

    #[test]
    fn intervals() {
        // Clamped, exactly as `periodic_due` clamps it: the two must not
        // describe different behaviour.
        assert_eq!(fmt_interval(0), "1 min");
        assert_eq!(fmt_interval(1), "1 min");
        assert_eq!(fmt_interval(59), "59 min");
        assert_eq!(fmt_interval(60), "1 h", "the shipped default");
        assert_eq!(fmt_interval(90), "1 h 30 min");
        assert_eq!(fmt_interval(120), "2 h");
        // A whole day is the one multiple-of-1440 case that stays in hours.
        assert_eq!(fmt_interval(1440), "24 h");
        assert_eq!(fmt_interval(2880), "2 days");
        assert_eq!(fmt_interval(10_080), "7 days");
    }

    #[test]
    fn thousands() {
        assert_eq!(group_thousands(0), "0");
        assert_eq!(group_thousands(999), "999");
        assert_eq!(group_thousands(1000), "1,000");
        assert_eq!(group_thousands(1_234_567), "1,234,567");
    }

    #[test]
    fn rates_never_show_zero_for_nonzero() {
        assert_eq!(fmt_rate(0.0), "0 files/s");
        assert_eq!(fmt_rate(2543.0), "2543 files/s");
        // Not 3.14: clippy reads that as a botched `PI` and denies it.
        assert_eq!(fmt_rate(3.12), "3.1 files/s");
        assert_eq!(fmt_rate(0.4), "24 files/min");
        assert_eq!(fmt_rate(0.001), "1 files/min", "floor at 1/min, never 0.0");
    }

    #[test]
    fn elapsed_units() {
        use std::time::Duration;
        assert_eq!(fmt_elapsed(Duration::from_millis(0)), "0 ms");
        assert_eq!(fmt_elapsed(Duration::from_millis(7)), "7 ms");
        assert_eq!(fmt_elapsed(Duration::from_millis(999)), "999 ms");
        assert_eq!(fmt_elapsed(Duration::from_millis(1000)), "1.0 s");
        assert_eq!(fmt_elapsed(Duration::from_millis(2340)), "2.3 s");
    }

    #[test]
    fn ago_buckets() {
        let now = quicksearch_core::log::now_unix();
        assert_eq!(fmt_ago(now), "just now");
        assert_eq!(fmt_ago(now - 59), "just now");
        assert_eq!(fmt_ago(now - 120), "2 min ago");
        assert_eq!(fmt_ago(now - 7200), "2 h ago");
        assert!(fmt_ago(now - 200_000).contains('-'), "old = absolute date");
    }

    #[test]
    fn reconcile_summaries_omit_what_did_not_happen() {
        assert_eq!(
            fmt_reconcile_summary(0, 0),
            "Configuration change applied",
            "a pass with nothing to do still reports that it ran"
        );
        assert_eq!(
            fmt_reconcile_summary(1, 0),
            "Configuration change applied · 1 entry removed"
        );
        assert_eq!(
            fmt_reconcile_summary(1204, 0),
            "Configuration change applied · 1,204 entries removed"
        );
        assert_eq!(
            fmt_reconcile_summary(0, 7),
            "Configuration change applied · 7 entries re-examined"
        );
        assert_eq!(
            fmt_reconcile_summary(2, 3),
            "Configuration change applied · 2 entries removed · 3 entries re-examined"
        );
    }

    #[test]
    fn truncation() {
        assert_eq!(middle_truncate("short", 20), "short");
        let t = middle_truncate("/very/long/path/to/some/file.txt", 15);
        assert!(t.chars().count() <= 15);
        assert!(t.contains('…'));
        assert!(t.starts_with("/very"));
        assert!(t.ends_with("e.txt"));
    }
}
