//! Client-side mirror of the server's cross-platform naming rules
//! (`CambiumServer/utilities/safenames.py`), ported from `TauriClient/src/data/safeNames.js`.
//! Lets the file tree flag a name the server would refuse *before* the user
//! tries to submit — shown as a "Naming issue" status instead of letting a
//! doomed file sit there marked green for add.
//!
//! The server stays authoritative; this is a fast, friendly heads-up. A path
//! here is a depot-relative path with '/' separators ('Assets/Art/Knight.spp').

use std::collections::HashMap;

use unicode_normalization::UnicodeNormalization;

const ILLEGAL_CHARS: &[char] = &['<', '>', ':', '"', '|', '?', '*', '\\'];
const MAX_COMPONENT_BYTES: usize = 255;
// Depot-path budget in UTF-16 code units (the unit Windows counts against its
// 260-unit MAX_PATH for the ABSOLUTE path). 200 leaves >= 59 units for the
// workspace root. Mirrors CambiumServer/utilities/safenames.py MAX_PATH_UTF16 — keep the
// two (and the JS copy) in sync.
const MAX_PATH_UTF16: usize = 200;

fn is_reserved(stem: &str) -> bool {
    matches!(
        stem,
        "CON"
            | "PRN"
            | "AUX"
            | "NUL"
            | "COM1"
            | "COM2"
            | "COM3"
            | "COM4"
            | "COM5"
            | "COM6"
            | "COM7"
            | "COM8"
            | "COM9"
            | "LPT1"
            | "LPT2"
            | "LPT3"
            | "LPT4"
            | "LPT5"
            | "LPT6"
            | "LPT7"
            | "LPT8"
            | "LPT9"
    )
}

/// Why a single path component is unsafe, or `None` if it's fine. Mirrors
/// `componentReason` in safeNames.js verbatim, including message wording and
/// quote style (curly quotes for interpolated names, straight quotes for the
/// literal "." / ".." case).
fn component_reason(name: &str) -> Option<String> {
    if name.is_empty() {
        return Some("has an empty folder or file name".to_string());
    }
    if name == "." || name == ".." {
        return Some(format!("contains a \"{name}\" path segment"));
    }
    // Iterated per Unicode scalar value, same as JS's `for...of` over a
    // string (which walks code points, not UTF-16 units).
    for ch in name.chars() {
        if (ch as u32) < 0x20 {
            return Some(format!("\u{201c}{name}\u{201d} contains a control character"));
        }
    }
    let illegal: String = name
        .chars()
        .filter(|ch| ILLEGAL_CHARS.contains(ch))
        .map(|ch| ch.to_string())
        .collect::<Vec<_>>()
        .join(" ");
    if !illegal.is_empty() {
        return Some(format!(
            "\u{201c}{name}\u{201d} uses {illegal} \u{2014} not allowed on Windows"
        ));
    }
    if name != name.trim_end_matches(['.', ' ']) {
        return Some(format!("\u{201c}{name}\u{201d} ends with a dot or space"));
    }
    if name != name.trim_start_matches(' ') {
        return Some(format!("\u{201c}{name}\u{201d} starts with a space"));
    }
    let stem = name.splitn(2, '.').next().unwrap_or("").to_uppercase();
    if is_reserved(&stem) {
        return Some(format!(
            "\u{201c}{name}\u{201d} is a reserved device name on Windows"
        ));
    }
    if name.len() > MAX_COMPONENT_BYTES {
        return Some("a folder or file name is too long".to_string());
    }
    None
}

/// Why a depot path is unsafe on some filesystem, or `None` if it's fine.
pub fn unsafe_reason(path: &str) -> Option<String> {
    if path.encode_utf16().count() > MAX_PATH_UTF16 {
        return Some(format!(
            "the whole path is longer than {MAX_PATH_UTF16} characters (Windows path-length budget)"
        ));
    }
    for component in path.split('/') {
        if let Some(reason) = component_reason(component) {
            return Some(reason);
        }
    }
    None
}

/// The identity a case-insensitive, normalizing filesystem collapses a name
/// to. Must match JS's `component.normalize('NFC').toLowerCase()` exactly —
/// NFC (not NFD, not casefold).
fn fold(component: &str) -> String {
    component.nfc().collect::<String>().to_lowercase()
}

/// Build a folded-node index over a set of depot paths: every folded
/// ancestor-or-file node -> the set of original spellings that share it.
/// Used to spot case/normalization collisions ('README.md' vs 'readme.MD',
/// 'Art/' vs 'art/') without re-walking every path for each candidate.
pub fn build_folded_index<'a>(paths: impl Iterator<Item = &'a str>) -> HashMap<String, Vec<String>> {
    let mut index: HashMap<String, Vec<String>> = HashMap::new();
    for path in paths {
        let parts: Vec<&str> = path.split('/').collect();
        for i in 0..parts.len() {
            let folded = parts[..=i].iter().map(|p| fold(p)).collect::<Vec<_>>().join("/");
            let spelling = parts[..=i].join("/");
            let set = index.entry(folded).or_default();
            if !set.contains(&spelling) {
                set.push(spelling);
            }
        }
    }
    index
}

/// Why `path` collides with a differently-spelled node in `index`, or `None`.
pub fn collision_in_index(path: &str, index: &HashMap<String, Vec<String>>) -> Option<String> {
    let parts: Vec<&str> = path.split('/').collect();
    for i in 0..parts.len() {
        let spelling = parts[..=i].join("/");
        let folded = parts[..=i].iter().map(|p| fold(p)).collect::<Vec<_>>().join("/");
        if let Some(set) = index.get(&folded) {
            for other in set {
                if other != &spelling {
                    return Some(format!(
                        "clashes with \u{201c}{other}\u{201d} when case or accents are ignored"
                    ));
                }
            }
        }
    }
    None
}

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

    // ---- ported 1:1 from TauriClient/src/data/safeNames.test.js -----------

    #[test]
    fn accepts_a_depot_path_of_exactly_200_utf16_units() {
        let exactly_200 = format!("{}/{}.txt", "a".repeat(99), "a".repeat(96));
        assert_eq!(exactly_200.encode_utf16().count(), 200);
        assert_eq!(unsafe_reason(&exactly_200), None);
    }

    #[test]
    fn rejects_201_units() {
        let over_by_one = format!("{}/{}.txt", "a".repeat(100), "a".repeat(96));
        assert_eq!(over_by_one.encode_utf16().count(), 201);
        let reason = unsafe_reason(&over_by_one).unwrap();
        assert!(reason.contains("longer than 200"), "{reason}");
    }

    #[test]
    fn counts_astral_characters_as_two_units_like_windows() {
        let fits = format!("{}/\u{1F600}", "a".repeat(197)); // 197 + 1 + 2 = 200
        assert_eq!(unsafe_reason(&fits), None);
        let over = format!("{}/\u{1F600}", "a".repeat(198)); // 201
        assert!(unsafe_reason(&over).is_some());
    }

    #[test]
    fn does_not_limit_nesting_depth_itself_only_total_length() {
        let deep_but_short = vec!["a"; 100].join("/"); // 199 units, 100 levels
        assert_eq!(unsafe_reason(&deep_but_short), None);
    }

    // ---- additional component-rule cases (from componentReason in safeNames.js) --

    #[test]
    fn empty_component_is_unsafe() {
        assert_eq!(
            unsafe_reason("Assets//foo.txt"),
            Some("has an empty folder or file name".to_string())
        );
    }

    #[test]
    fn dot_and_dotdot_segments_are_unsafe() {
        assert_eq!(
            unsafe_reason("Assets/./foo.txt"),
            Some("contains a \".\" path segment".to_string())
        );
        assert_eq!(
            unsafe_reason("Assets/../foo.txt"),
            Some("contains a \"..\" path segment".to_string())
        );
    }

    #[test]
    fn control_characters_are_unsafe() {
        let name = "foo\u{0001}bar.txt";
        let reason = unsafe_reason(name).unwrap();
        assert!(reason.contains("contains a control character"), "{reason}");
    }

    #[test]
    fn illegal_windows_characters_are_unsafe() {
        let reason = unsafe_reason("Assets/na<me>.txt").unwrap();
        assert!(reason.contains("not allowed on Windows"), "{reason}");
    }

    #[test]
    fn trailing_dot_or_space_is_unsafe() {
        assert!(unsafe_reason("Assets/foo. ").unwrap().contains("ends with a dot or space"));
        assert!(unsafe_reason("Assets/foo.").unwrap().contains("ends with a dot or space"));
    }

    #[test]
    fn leading_space_is_unsafe() {
        assert!(unsafe_reason("Assets/ foo.txt").unwrap().contains("starts with a space"));
    }

    #[test]
    fn reserved_windows_device_names_are_unsafe() {
        assert!(unsafe_reason("Assets/CON").unwrap().contains("reserved device name"));
        assert!(unsafe_reason("Assets/con.txt").unwrap().contains("reserved device name"));
        assert!(unsafe_reason("Assets/COM3.log").unwrap().contains("reserved device name"));
        assert_eq!(unsafe_reason("Assets/CONSOLE.txt"), None);
    }

    #[test]
    fn overlong_component_is_unsafe() {
        // '€' (U+20AC) is 1 UTF-16 unit but 3 UTF-8 bytes: 90 of them keep
        // the whole-path UTF-16 budget (200) well clear while pushing the
        // component's UTF-8 byte length (270) past MAX_COMPONENT_BYTES
        // (255) — isolates the byte-length check from the path-length one.
        let long_name = "\u{20ac}".repeat(90);
        assert!(long_name.encode_utf16().count() < 200);
        assert!(long_name.len() > 255);
        let reason = unsafe_reason(&long_name).unwrap();
        assert!(reason.contains("too long"), "{reason}");
    }

    #[test]
    fn safe_names_pass() {
        assert_eq!(unsafe_reason("Assets/Art/Knight.spp"), None);
    }

    // ---- collision index --------------------------------------------------

    #[test]
    fn exact_match_is_not_a_collision() {
        let index = build_folded_index(vec!["a.txt", "b.txt"].into_iter());
        assert_eq!(collision_in_index("a.txt", &index), None);
    }

    #[test]
    fn case_only_difference_is_a_collision() {
        let index = build_folded_index(vec!["a.txt"].into_iter());
        assert!(collision_in_index("A.txt", &index).is_some());

        let index2 = build_folded_index(vec!["texture.png"].into_iter());
        assert!(collision_in_index("Texture.PNG", &index2).is_some());
    }

    #[test]
    fn folder_level_case_collision() {
        let index = build_folded_index(vec!["Art/old.txt"].into_iter());
        assert!(collision_in_index("art/new.txt", &index).is_some());
    }

    #[test]
    fn unicode_normalization_collision() {
        // "café.txt": precomposed (NFC, é = U+00E9) vs decomposed (NFD, e +
        // combining acute U+0065 U+0301). Mirrors
        // CambiumServer/tests/test_safenames.py::test_unicode_normalization_collision.
        let nfc = "caf\u{00e9}.txt";
        let nfd = "cafe\u{0301}.txt";
        let index = build_folded_index(vec![nfc].into_iter());
        assert!(collision_in_index(nfd, &index).is_some());
    }

    #[test]
    fn unrelated_names_do_not_collide() {
        let index = build_folded_index(vec!["Art/old.txt", "b.txt"].into_iter());
        assert_eq!(collision_in_index("Assets/new.txt", &index), None);
    }
}
