//! Generate temp project directory names (P-NAME-1, ADR-0015 §2).
//!
//! Output pattern: `<YYYYMMDD>-[temp]-<word>-<word>-<word>`
//! where the three words are distinct picks from a small
//! built-in wordlist compiled into the binary. The literal
//! `[temp]` segment is what marks a directory as an
//! auto-named temporary project — `validate_user_name` rejects
//! `[` and `]` for user-supplied names, so the marker can
//! never collide with a saved project's directory.
//!
//! Collisions against existing entries in the data root are
//! detected and the slug is regenerated; we cap retries at a
//! generous number to turn the theoretical never-give-up loop
//! into a clean failure if something is profoundly wrong
//! (e.g. wordlist inadvertently truncated to a handful of
//! items).

use std::path::Path;

use rand::Rng;
use rand::seq::IndexedRandom;

const WORDLIST: &str = include_str!("wordlist.txt");
const MAX_COLLISION_RETRIES: usize = 100;

/// All non-empty, non-comment lines from the wordlist.
fn words() -> Vec<&'static str> {
    WORDLIST
        .lines()
        .map(str::trim)
        .filter(|l| !l.is_empty() && !l.starts_with('#'))
        .collect()
}

#[derive(Debug)]
pub enum NamingError {
    WordlistTooSmall(usize),
    TooManyCollisions(usize),
}

impl std::fmt::Display for NamingError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::WordlistTooSmall(n) => {
                f.write_str(&crate::t!("project.naming.wordlist_too_small", count = n,))
            }
            Self::TooManyCollisions(n) => f.write_str(&crate::t!(
                "project.naming.too_many_collisions",
                attempts = n,
            )),
        }
    }
}

impl std::error::Error for NamingError {}

/// Generate a fresh temp project directory name.
///
/// Checks for collisions against `parent_dir` (typically
/// `<data-root>/projects/`). The `today` callback returns the
/// `YYYYMMDD` prefix; injecting it makes the function
/// deterministic in tests.
///
/// Returns `Err(WordlistTooSmall)` if the wordlist contains
/// fewer than three entries; returns `Err(TooManyCollisions)`
/// only if `MAX_COLLISION_RETRIES` regenerations all collided
/// (effectively impossible with a healthy wordlist).
pub fn generate_temp_name<R: Rng + ?Sized>(
    rng: &mut R,
    parent_dir: &Path,
    today: impl Fn() -> String,
) -> Result<String, NamingError> {
    let pool = words();
    if pool.len() < 3 {
        return Err(NamingError::WordlistTooSmall(pool.len()));
    }

    for _ in 0..MAX_COLLISION_RETRIES {
        let date = today();
        let slug = three_distinct_words(rng, &pool);
        let candidate = format!("{date}-{TEMP_MARKER}-{slug}");
        if !parent_dir.join(&candidate).exists() {
            return Ok(candidate);
        }
    }
    Err(NamingError::TooManyCollisions(MAX_COLLISION_RETRIES))
}

/// Literal segment marking a directory as a temp project.
///
/// Distinguishes auto-named temp projects from user-named
/// ones in the directory listing. Brackets are reserved
/// (rejected by `validate_user_name`) so this never collides
/// with a saved project's name.
pub const TEMP_MARKER: &str = "[temp]";

/// Pick three distinct words from the pool and join them with
/// `-`. Uses `choose_multiple` so the picks are always distinct
/// without needing manual deduplication.
fn three_distinct_words<R: Rng + ?Sized>(rng: &mut R, pool: &[&'static str]) -> String {
    let chosen: Vec<&str> = pool.sample(rng, 3).copied().collect();
    chosen.join("-")
}

/// `YYYYMMDD` for the local date today.
///
/// Suitable as the default `today` callback for production use.
#[must_use]
pub fn today_local() -> String {
    // We intentionally don't take a chrono dep just for this;
    // a SystemTime split into Y/M/D is enough.
    let now = std::time::SystemTime::now();
    let secs = now
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    let (y, m, d) = ymd_from_unix_secs(secs);
    format!("{y:04}{m:02}{d:02}")
}

/// Convert Unix seconds to a (year, month, day) tuple.
///
/// Local time would be the proper choice; we use UTC to avoid
/// pulling a timezone crate, accepting that on the day
/// boundary a temp project may be tagged with the previous (or
/// next) UTC day. Names are still unique and sortable.
const fn ymd_from_unix_secs(secs: i64) -> (u32, u32, u32) {
    // Algorithm from Howard Hinnant's "civil_from_days" — a
    // well-known closed-form conversion that doesn't need
    // chrono. https://howardhinnant.github.io/date_algorithms.html
    let days = secs.div_euclid(86_400);
    let z = days + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = (z - era * 146_097) as u64;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    (y as u32, m as u32, d as u32)
}

/// Is this an auto-named temp project directory?
///
/// We look for the literal `[temp]` segment in the
/// dash-separated dirname. Because brackets are reserved
/// (rejected by `validate_user_name`), this is unambiguous: a
/// user-named project can never produce a directory whose
/// name contains `[temp]`. Dir-name-only check keeps the
/// load picker fast — no YAML parse needed per project.
#[must_use]
pub fn is_temp_dirname(dirname: &str) -> bool {
    dirname.split('-').any(|seg| seg == TEMP_MARKER)
}

/// Validate a user-supplied project directory name.
///
/// Returns `Ok(())` if the name is acceptable, or an error
/// describing why not. We deliberately stay conservative:
/// alphanumerics, `-`, `_`, and `.` only. No path separators,
/// no leading dot, no empty.
pub fn validate_user_name(name: &str) -> Result<(), UserNameError> {
    if name.is_empty() {
        return Err(UserNameError::Empty);
    }
    if name.starts_with('.') {
        return Err(UserNameError::LeadingDot);
    }
    for c in name.chars() {
        if !(c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') {
            return Err(UserNameError::InvalidChar(c));
        }
    }
    Ok(())
}

#[derive(Debug, PartialEq, Eq)]
pub enum UserNameError {
    Empty,
    LeadingDot,
    InvalidChar(char),
}

impl std::fmt::Display for UserNameError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Empty => f.write_str(&crate::t!("project.user_name.empty")),
            Self::LeadingDot => f.write_str(&crate::t!("project.user_name.leading_dot")),
            Self::InvalidChar(c) => {
                f.write_str(&crate::t!("project.user_name.invalid_char", ch = c,))
            }
        }
    }
}

impl std::error::Error for UserNameError {}

#[cfg(test)]
mod tests {
    use super::*;
    use rand::SeedableRng;
    use rand::rngs::StdRng;
    use std::fs;

    #[test]
    fn wordlist_has_enough_entries() {
        let pool = words();
        assert!(
            pool.len() >= 100,
            "wordlist suspiciously small: {} entries",
            pool.len()
        );
    }

    #[test]
    fn wordlist_has_no_duplicates() {
        let pool = words();
        let unique: std::collections::HashSet<_> = pool.iter().collect();
        assert_eq!(
            unique.len(),
            pool.len(),
            "wordlist contains duplicate entries"
        );
    }

    #[test]
    fn wordlist_is_lowercase_kebab_safe() {
        for w in words() {
            assert!(
                w.chars().all(|c| c.is_ascii_lowercase()),
                "wordlist entry {w:?} should be all-lowercase ASCII"
            );
        }
    }

    #[test]
    fn generates_well_formed_name() {
        let tmp = tempdir();
        let mut rng = StdRng::seed_from_u64(42);
        let name = generate_temp_name(&mut rng, tmp.path(), || "20260507".to_string()).unwrap();
        assert!(name.starts_with("20260507-[temp]-"), "got: {name}");
        // After the date and the [temp] marker, three distinct
        // word segments separated by `-`.
        let segments: Vec<&str> = name.split('-').collect();
        assert_eq!(segments[0], "20260507");
        assert_eq!(segments[1], "[temp]");
        let words_in_name: std::collections::HashSet<_> = segments[2..].iter().collect();
        assert_eq!(words_in_name.len(), 3, "words must be distinct: {name}");
        assert!(is_temp_dirname(&name));
    }

    #[test]
    fn detects_collision_and_regenerates() {
        let tmp = tempdir();
        let mut rng = StdRng::seed_from_u64(1);
        let first = generate_temp_name(&mut rng, tmp.path(), || "20260507".to_string()).unwrap();
        fs::create_dir(tmp.path().join(&first)).unwrap();

        // Use the same seed: the first call would deterministically
        // produce `first` again. After the collision check it
        // regenerates and yields something different.
        let mut rng = StdRng::seed_from_u64(1);
        let second = generate_temp_name(&mut rng, tmp.path(), || "20260507".to_string()).unwrap();
        assert_ne!(first, second, "should have regenerated past the collision");
    }

    #[test]
    fn ymd_from_known_unix_seconds() {
        // 2026-05-07 00:00:00 UTC = 1778112000.
        assert_eq!(ymd_from_unix_secs(1_778_112_000), (2026, 5, 7));
        // Epoch.
        assert_eq!(ymd_from_unix_secs(0), (1970, 1, 1));
        // 2000-01-01.
        assert_eq!(ymd_from_unix_secs(946_684_800), (2000, 1, 1));
        // 2024-02-29 (leap day, sanity check).
        assert_eq!(ymd_from_unix_secs(1_709_164_800), (2024, 2, 29));
    }

    #[test]
    fn today_local_format() {
        let s = today_local();
        assert_eq!(s.len(), 8);
        assert!(s.chars().all(|c| c.is_ascii_digit()), "today_local: {s}");
    }

    #[test]
    fn is_temp_dirname_examples() {
        // Generated form.
        assert!(is_temp_dirname("20260507-[temp]-water-buffalo-skating"));
        assert!(is_temp_dirname("19991231-[temp]-amber-cosmic-flying"));

        // Named projects: cannot contain `[temp]` because
        // validate_user_name rejects brackets.
        assert!(!is_temp_dirname("MyOrders"));
        assert!(!is_temp_dirname("term_planner"));
        assert!(!is_temp_dirname("20260507-water-buffalo-skating")); // no marker
        assert!(!is_temp_dirname("temp-project")); // no brackets
    }

    #[test]
    fn validates_user_name() {
        assert!(validate_user_name("MyProject").is_ok());
        assert!(validate_user_name("my-project").is_ok());
        assert!(validate_user_name("my_project").is_ok());
        assert!(validate_user_name("project.v2").is_ok());

        assert_eq!(validate_user_name(""), Err(UserNameError::Empty));
        assert_eq!(
            validate_user_name(".hidden"),
            Err(UserNameError::LeadingDot)
        );
        assert!(matches!(
            validate_user_name("a/b"),
            Err(UserNameError::InvalidChar('/'))
        ));
        assert!(matches!(
            validate_user_name("a b"),
            Err(UserNameError::InvalidChar(' '))
        ));
    }

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