use nodejs_semver::{Range, Version as NodeVersion};
use std::cmp::Ordering;
use versions::{Mess, Versioning};

/// splits a version number into an optional prefix and the remaining version string
pub fn split_version_prefix(version: &str) -> (String, String) {
    let mut prev_char = None;
    for (i, c) in version.char_indices() {
        if c.is_ascii_digit()
            && (i == 0 || prev_char.is_some_and(|c| ['-', '_', '/', '.', 'v', 'V'].contains(&c)))
        {
            let (prefix, version) = version.split_at(i);
            return (prefix.into(), version.into());
        }
        prev_char = Some(c);
    }
    ("".into(), version.into())
}

/// split a version number into chunks
/// given v: "1.2-3a4" return ["1", ".2", "-3a4"]
pub fn chunkify_version(v: &str) -> Vec<String> {
    fn chunkify(m: &Mess, sep0: &str, chunks: &mut Vec<String>) {
        for (i, chunk) in m.chunks.iter().enumerate() {
            let sep = if i == 0 { sep0 } else { "." };
            chunks.push(format!("{sep}{chunk}"));
        }
        if let Some((next_sep, next_mess)) = &m.next {
            chunkify(next_mess, next_sep.to_string().as_ref(), chunks)
        }
    }

    let mut chunks = vec![];
    // don't parse "latest", otherwise bump from latest to any version would have one chunk only
    if v != "latest"
        && let Some(v) = Versioning::new(v)
    {
        let m = match v {
            Versioning::Ideal(sem_ver) => sem_ver.to_mess(),
            Versioning::General(version) => version.to_mess(),
            Versioning::Complex(mess) => mess,
        };
        chunkify(&m, "", &mut chunks);
    }
    chunks
}

/// Filter a list of version strings with an npm-compatible semver range.
///
/// Returns `None` for non-range queries so callers can fall back to mise's
/// existing fuzzy matching for aliases and non-semver tools.
pub fn npm_semver_range_filter(versions: &[String], query: &str) -> Option<Vec<String>> {
    let query = query.trim();
    if !is_npm_semver_range_query(query) {
        return None;
    }
    let range = Range::parse(query).ok()?;

    Some(
        versions
            .iter()
            .filter(|v| {
                let version = v.as_str();
                NodeVersion::parse(version)
                    .or_else(|_| NodeVersion::parse(version.trim_start_matches(['v', 'V'])))
                    .is_ok_and(|version| range.satisfies(&version))
            })
            .cloned()
            .collect(),
    )
}

pub fn is_npm_semver_range_query(query: &str) -> bool {
    if query.is_empty() || query.eq_ignore_ascii_case("latest") {
        return false;
    }
    // A real npm range never contains a control character, but `split_whitespace` below counts
    // tabs and newlines as separators, so a string that was mangled before it got here looks like
    // a multi-part range. TOML reading `\t` as a tab in `path = "C:\tools\node"` is how that
    // happens in practice. Calling such a value a semver range points the reader at versions when
    // the problem is the value itself; the validation that follows already names the real cause.
    if query.chars().any(|c| c.is_control()) {
        return false;
    }
    if query == "*" || query.eq_ignore_ascii_case("x") {
        return true;
    }
    if query.contains("||") || query.contains(" - ") {
        return true;
    }
    if matches!(
        query.as_bytes().first().copied(),
        Some(b'<' | b'>' | b'=' | b'^' | b'~')
    ) || query.contains('<')
        || query.contains('>')
    {
        return true;
    }
    if query.split_whitespace().count() > 1 {
        return true;
    }
    query.split('.').any(|part| matches!(part, "*" | "x" | "X"))
}

pub fn semver_triplet(version: &str) -> Option<(u64, u64, u64)> {
    let trimmed = version.trim().trim_start_matches(['v', 'V']);
    let mut parts = trimmed.split('.');
    let major = parts.next()?.parse().ok()?;
    let minor = parts.next()?.parse().ok()?;
    let patch = parts.next()?.split(['-', '+']).next()?.parse().ok()?;
    Some((major, minor, patch))
}

pub fn semver_cmp(version: &str, other: &str) -> Option<Ordering> {
    Some(semver_triplet(version)?.cmp(&semver_triplet(other)?))
}

pub fn semver_is_older_than(version: &str, minimum: &str) -> Option<bool> {
    Some(semver_cmp(version, minimum)? == Ordering::Less)
}

pub fn semver_is_at_least(version: &str, minimum: &str) -> Option<bool> {
    Some(semver_cmp(version, minimum)? != Ordering::Less)
}

#[cfg(test)]
mod tests {
    use super::{
        chunkify_version, is_npm_semver_range_query, npm_semver_range_filter, semver_cmp,
        semver_is_at_least, semver_is_older_than, semver_triplet, split_version_prefix,
    };
    use std::cmp::Ordering;

    #[test]
    fn test_split_version_prefix() {
        assert_eq!(split_version_prefix("latest"), ("".into(), "latest".into()));
        assert_eq!(split_version_prefix("v1.2.3"), ("v".into(), "1.2.3".into()));
        assert_eq!(
            split_version_prefix("mountpoint-s3-v1.2.3-5_beta.5"),
            ("mountpoint-s3-v".into(), "1.2.3-5_beta.5".into())
        );
        assert_eq!(
            split_version_prefix("cli/1.2.3"),
            ("cli/".into(), "1.2.3".into())
        );
        assert_eq!(
            split_version_prefix("temurin-17.0.7+7"),
            ("temurin-".into(), "17.0.7+7".into())
        );
        assert_eq!(
            split_version_prefix("\u{5de5}\u{5177}-v1.2.3"),
            ("\u{5de5}\u{5177}-v".into(), "1.2.3".into())
        );
        assert_eq!(split_version_prefix("1.2"), ("".into(), "1.2".into()));
        assert_eq!(
            split_version_prefix("2:1.2.1"),
            ("".into(), "2:1.2.1".into())
        );
        assert_eq!(
            split_version_prefix("2025-05-17"),
            ("".into(), "2025-05-17".into())
        );
    }

    #[test]
    fn test_chunkify_version() {
        assert_eq!(chunkify_version("1.2-3a4"), vec!["1", ".2", "-3a4"]);
        assert_eq!(chunkify_version("latest"), Vec::<String>::new());
        assert_eq!(chunkify_version("1.0.0"), vec!["1", ".0", ".0"]);
        assert_eq!(
            chunkify_version("2.3.4-beta"),
            vec!["2", ".3", ".4", "-beta"]
        );
    }

    #[test]
    fn test_npm_semver_range_filter_lower_bound() {
        let versions = ["25.5.0", "25.6.1", "25.8.2"].map(String::from).to_vec();

        assert_eq!(
            npm_semver_range_filter(&versions, ">=25.6.1").unwrap(),
            vec!["25.6.1".to_string(), "25.8.2".to_string()]
        );
        assert_eq!(
            npm_semver_range_filter(&versions, ">= 25.6.1").unwrap(),
            vec!["25.6.1".to_string(), "25.8.2".to_string()]
        );
    }

    #[test]
    fn test_npm_semver_range_filter_compound_bounds() {
        let versions = ["25.5.0", "25.6.1", "25.8.2", "26.0.0"]
            .map(String::from)
            .to_vec();

        assert_eq!(
            npm_semver_range_filter(&versions, ">=25.6.1 <26").unwrap(),
            vec!["25.6.1".to_string(), "25.8.2".to_string()]
        );
    }

    #[test]
    fn test_npm_semver_range_filter_caret() {
        let versions = ["20.0.0", "20.0.1", "20.1.0", "21.0.0"]
            .map(String::from)
            .to_vec();

        assert_eq!(
            npm_semver_range_filter(&versions, "^20.0.1").unwrap(),
            vec!["20.0.1".to_string(), "20.1.0".to_string()]
        );
    }

    #[test]
    fn test_npm_semver_range_filter_alternatives() {
        let versions = ["18.19.0", "20.0.0", "21.9.0", "22.0.0"]
            .map(String::from)
            .to_vec();

        assert_eq!(
            npm_semver_range_filter(&versions, ">=18 <20 || >=22").unwrap(),
            vec!["18.19.0".to_string(), "22.0.0".to_string()]
        );
    }

    #[test]
    fn test_npm_semver_range_filter_preserves_v_prefix() {
        let versions = ["v25.6.1", "v25.8.2"].map(String::from).to_vec();

        assert_eq!(
            npm_semver_range_filter(&versions, ">=25.8.0").unwrap(),
            vec!["v25.8.2".to_string()]
        );
    }

    #[test]
    fn test_npm_semver_range_filter_non_range_queries_fall_back() {
        assert_eq!(
            npm_semver_range_filter(&["1.0.0".to_string()], "latest"),
            None
        );
        assert_eq!(
            npm_semver_range_filter(&["1.0.0".to_string()], "temurin-"),
            None
        );
        assert_eq!(
            npm_semver_range_filter(&["1.0.0".to_string()], "1.0.0"),
            None
        );
    }

    #[test]
    fn test_npm_semver_range_query_ignores_mangled_values() {
        // A value broken before it reached here is not a range. TOML reads `\t` in
        // `path = "C:\tools\node"` as a tab and `\n` as a newline, which `split_whitespace`
        // counts as separators -- so it used to be reported as a multi-part semver range, with
        // no mention of the path that was actually wrong.
        assert!(!is_npm_semver_range_query("path:C:\tools\node"));
        assert!(!is_npm_semver_range_query("1.0\t2.0"));
        assert!(!is_npm_semver_range_query("1.0\n2.0"));
    }

    #[test]
    fn test_npm_semver_range_query_still_detects_real_ranges() {
        // The control for the test above: skipping control characters must not stop any range
        // from being detected. None of these contain one.
        for q in [
            "^1.2.3",
            ">=1.0 <2.0",
            "1.x",
            "*",
            "1.2 - 1.5",
            "1.0 || 2.0",
        ] {
            assert!(is_npm_semver_range_query(q), "expected {q:?} to be a range");
        }
        // Ordinary values stay non-ranges, including a well-formed `path:`.
        for q in [
            "1.0.0",
            "latest",
            "path:C:/tools/node",
            "path:/opt/homebrew/opt/node@20",
        ] {
            assert!(
                !is_npm_semver_range_query(q),
                "expected {q:?} not to be a range"
            );
        }
    }

    #[test]
    fn test_semver_triplet() {
        assert_eq!(semver_triplet("1.2.3"), Some((1, 2, 3)));
        assert_eq!(semver_triplet("v1.2.3"), Some((1, 2, 3)));
        assert_eq!(semver_triplet("V1.2.3"), Some((1, 2, 3)));
        assert_eq!(semver_triplet("1.2.3-pre.1"), Some((1, 2, 3)));
        assert_eq!(semver_triplet("1.2.3+build.1"), Some((1, 2, 3)));
        assert_eq!(semver_triplet("1.2"), None);
        assert_eq!(semver_triplet("latest"), None);
        assert_eq!(semver_triplet("garbage"), None);
    }

    #[test]
    fn test_semver_cmp() {
        assert_eq!(semver_cmp("1.2.9", "1.3.0"), Some(Ordering::Less));
        assert_eq!(semver_cmp("1.3.0", "1.3.0"), Some(Ordering::Equal));
        assert_eq!(semver_cmp("1.3.1", "1.3.0"), Some(Ordering::Greater));
        assert_eq!(semver_cmp("latest", "1.3.0"), None);
    }

    #[test]
    fn test_semver_minimum_helpers() {
        assert_eq!(semver_is_older_than("1.2.9", "1.3.0"), Some(true));
        assert_eq!(semver_is_older_than("1.3.0", "1.3.0"), Some(false));
        assert_eq!(semver_is_older_than("v1.3.1", "1.3.0"), Some(false));
        assert_eq!(semver_is_older_than("latest", "1.3.0"), None);

        assert_eq!(semver_is_at_least("1.2.9", "1.3.0"), Some(false));
        assert_eq!(semver_is_at_least("1.3.0", "1.3.0"), Some(true));
        assert_eq!(semver_is_at_least("v1.3.1", "1.3.0"), Some(true));
        assert_eq!(semver_is_at_least("latest", "1.3.0"), None);
    }
}
