use anyhow::{Context, Result};
use std::path::Path;
use walkdir::WalkDir;

use crate::dependency::ResolvedDependency;
use crate::lifecycle::BuildContext;

/// JUnit framework version detected from project dependencies.
#[derive(Debug, Clone, PartialEq, Eq)]
enum JunitVersion {
    /// JUnit Platform (JUnit 5) – uses `ConsoleLauncher`.
    Platform,
    /// JUnit 4 – uses `org.junit.runner.JUnitCore`.
    Junit4,
}

/// Run JUnit tests for the project.
///
/// Discovers compiled test classes, builds a classpath from the main and test
/// output directories plus all resolved dependency JARs, detects the JUnit
/// version on the classpath, and invokes the appropriate runner.
///
/// # Errors
///
/// Returns an error if test discovery or execution fails, or if any test fails.
pub fn run_junit_tests(context: &BuildContext, resolved_deps: &[ResolvedDependency]) -> Result<()> {
    let build = context.pom.effective_build();
    let test_classes_dir = context.work_dir.join(&build.test_output_directory);
    let classes_dir = context.work_dir.join(&build.output_directory);

    if !test_classes_dir.exists() {
        println!("[INFO] No test classes found, skipping test execution");
        return Ok(());
    }

    let Some(version) = detect_junit_version(resolved_deps) else {
        println!("[WARNING] No supported JUnit dependency found. Skipping test execution.");
        println!(
            "[INFO] Add junit:junit (JUnit 4) or, for JUnit 5, both \
             org.junit.jupiter:junit-jupiter and either \
             org.junit.platform:junit-platform-console-standalone or \
             org.junit.platform:junit-platform-console to your dependencies."
        );
        return Ok(());
    };

    let test_classes = discover_test_classes(&test_classes_dir)?;
    if test_classes.is_empty() {
        println!("[INFO] No test classes matching naming conventions found");
        return Ok(());
    }

    let classpath = build_classpath(&test_classes_dir, &classes_dir, resolved_deps);

    match version {
        JunitVersion::Platform => {
            println!(
                "[INFO] Running {} test class(es) with JUnit Platform",
                test_classes.len()
            );
            run_junit_platform(&classpath, &test_classes_dir)
        }
        JunitVersion::Junit4 => {
            println!(
                "[INFO] Running {} test class(es) with JUnit 4",
                test_classes.len()
            );
            run_junit4(&classpath, &test_classes)
        }
    }
}

/// Build the full classpath string for the `java` invocation.
///
/// Order: test-classes → classes → dependency JARs.
fn build_classpath(
    test_classes_dir: &Path,
    classes_dir: &Path,
    resolved_deps: &[ResolvedDependency],
) -> String {
    let cp_sep = if cfg!(windows) { ";" } else { ":" };
    let mut cp_entries: Vec<String> = vec![
        test_classes_dir.to_string_lossy().into_owned(),
        classes_dir.to_string_lossy().into_owned(),
    ];
    cp_entries.extend(
        resolved_deps
            .iter()
            .map(|d| d.path.to_string_lossy().into_owned()),
    );
    cp_entries.join(cp_sep)
}

/// Detect whether JUnit Platform (5) or JUnit 4 is on the classpath.
///
/// Detection is intentionally conservative: the Platform runner
/// (`ConsoleLauncher`) is only selected when a console launcher artifact
/// (`junit-platform-console-standalone` or `junit-platform-console`) from
/// `org.junit.platform` is present. Having only `org.junit.jupiter` or
/// `org.junit.vintage` engine dependencies is not enough, and in that case
/// this function returns `None` because `ConsoleLauncher` may not be on the
/// classpath.
///
/// When a console launcher artifact is present the code always selects
/// `JunitVersion::Platform`, regardless of whether tests are written with
/// Jupiter or the Vintage engine. Only the standalone `junit:junit`
/// artifact (without a console launcher) triggers the pure JUnit 4 path.
/// If both a console launcher and `junit:junit` are present, the Platform
/// path takes precedence.
///
/// Returns `None` when neither is found.
fn detect_junit_version(deps: &[ResolvedDependency]) -> Option<JunitVersion> {
    // Only treat JUnit Platform as available if a console launcher artifact is present,
    // since the Platform execution path relies on org.junit.platform.console.ConsoleLauncher.
    let has_junit_platform = deps.iter().any(|d| {
        d.group_id == "org.junit.platform"
            && (d.artifact_id == "junit-platform-console-standalone"
                || d.artifact_id == "junit-platform-console")
    });

    let has_junit4 = deps
        .iter()
        .any(|d| d.group_id == "junit" && d.artifact_id == "junit");

    if has_junit_platform {
        Some(JunitVersion::Platform)
    } else if has_junit4 {
        Some(JunitVersion::Junit4)
    } else {
        None
    }
}

/// Discover test classes using Maven Surefire default naming conventions.
///
/// Matches: `Test*.class`, `*Test.class`, `*Tests.class`, `*TestCase.class`.
/// Inner classes (file names containing `$`) are excluded.
///
/// # Errors
///
/// Returns an error if the directory cannot be walked.
fn discover_test_classes(test_classes_dir: &Path) -> Result<Vec<String>> {
    let mut classes = Vec::new();

    for entry in WalkDir::new(test_classes_dir).into_iter() {
        let entry = entry.with_context(|| {
            format!(
                "Failed to walk test classes directory {}",
                test_classes_dir.display()
            )
        })?;
        let path = entry.path();
        match path.extension() {
            Some(ext) if ext == "class" => {}
            _ => continue,
        }

        let relative = path
            .strip_prefix(test_classes_dir)
            .with_context(|| format!("Failed to strip prefix from {}", path.display()))?;
        let class_path = relative.to_string_lossy();

        // Skip inner classes
        if class_path.contains('$') {
            continue;
        }

        // Convert path to fully-qualified class name:
        //   com/example/FooTest.class → com.example.FooTest
        let class_name = class_path
            .trim_end_matches(".class")
            .replace(['/', '\\'], ".");

        if is_test_class(&class_name) {
            classes.push(class_name);
        }
    }

    classes.sort();
    Ok(classes)
}

/// Check if a class name matches Maven Surefire default naming conventions.
fn is_test_class(class_name: &str) -> bool {
    let simple_name = class_name.rsplit('.').next().unwrap_or(class_name);
    simple_name.starts_with("Test")
        || simple_name.ends_with("Test")
        || simple_name.ends_with("Tests")
        || simple_name.ends_with("TestCase")
}

/// Run tests using JUnit 4's `JUnitCore`.
///
/// # Errors
///
/// Returns an error if `java` cannot be started or if any test fails.
fn run_junit4(classpath: &str, test_classes: &[String]) -> Result<()> {
    let mut cmd = std::process::Command::new("java");
    cmd.arg("-cp")
        .arg(classpath)
        .arg("org.junit.runner.JUnitCore");
    for class in test_classes {
        cmd.arg(class);
    }

    let status = cmd.status().with_context(|| {
        "Failed to invoke java. Make sure a JDK is installed and java is on your PATH."
    })?;

    if !status.success() {
        anyhow::bail!("Tests failed (exit code {:?})", status.code());
    }

    println!("[INFO] Tests passed");
    Ok(())
}

/// Maven Surefire default classname patterns as regular expressions for
/// JUnit Platform's `--include-classname` filter.
const SUREFIRE_CLASSNAME_PATTERNS: &[&str] =
    &["(^|\\.)Test.*", ".*Test$", ".*Tests$", ".*TestCase$"];

/// Regex pattern used with `--exclude-classname` to skip inner classes
/// (class names containing `$`) on the Platform path, matching the
/// inner-class exclusion applied by `discover_test_classes` on the JUnit 4
/// path.
const INNER_CLASS_EXCLUSION_PATTERN: &str = ".*\\$.*";

/// Run tests using JUnit Platform `ConsoleLauncher` (JUnit 5).
///
/// Applies Maven Surefire default naming patterns via `--include-classname`
/// so that only classes matching `Test*`, `*Test`, `*Tests`, or `*TestCase`
/// are executed.
///
/// # Errors
///
/// Returns an error if `java` cannot be started or if any test fails.
fn run_junit_platform(classpath: &str, test_classes_dir: &Path) -> Result<()> {
    let mut cmd = std::process::Command::new("java");
    cmd.arg("-cp")
        .arg(classpath)
        .arg("org.junit.platform.console.ConsoleLauncher")
        .arg("--scan-classpath")
        .arg(test_classes_dir);
    for pattern in SUREFIRE_CLASSNAME_PATTERNS {
        cmd.arg("--include-classname").arg(pattern);
    }
    cmd.arg("--exclude-classname")
        .arg(INNER_CLASS_EXCLUSION_PATTERN);
    let status = cmd.status().with_context(|| {
        "Failed to invoke java. Make sure a JDK is installed and java is on your PATH."
    })?;

    if !status.success() {
        anyhow::bail!("Tests failed (exit code {:?})", status.code());
    }

    println!("[INFO] Tests passed");
    Ok(())
}

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

    #[test]
    fn test_is_test_class_matches_suffix_test() {
        assert!(is_test_class("com.example.FooTest"));
    }

    #[test]
    fn test_is_test_class_matches_suffix_tests() {
        assert!(is_test_class("com.example.FooTests"));
    }

    #[test]
    fn test_is_test_class_matches_suffix_test_case() {
        assert!(is_test_class("com.example.FooTestCase"));
    }

    #[test]
    fn test_is_test_class_matches_prefix_test() {
        assert!(is_test_class("com.example.TestFoo"));
    }

    #[test]
    fn test_is_test_class_rejects_non_test() {
        assert!(!is_test_class("com.example.Foo"));
        assert!(!is_test_class("com.example.FooHelper"));
        assert!(!is_test_class("com.example.Attestation"));
    }

    #[test]
    fn test_is_test_class_simple_name_only() {
        assert!(is_test_class("MyTest"));
        assert!(!is_test_class("MyHelper"));
    }

    #[test]
    fn test_discover_test_classes_finds_matching() {
        let tmp = TempDir::new().unwrap();
        let pkg = tmp.path().join("com").join("example");
        std::fs::create_dir_all(&pkg).unwrap();
        std::fs::write(pkg.join("FooTest.class"), b"").unwrap();
        std::fs::write(pkg.join("BarTests.class"), b"").unwrap();
        std::fs::write(pkg.join("TestBaz.class"), b"").unwrap();
        std::fs::write(pkg.join("QuxTestCase.class"), b"").unwrap();

        let classes = discover_test_classes(tmp.path()).unwrap();
        assert_eq!(
            classes,
            vec![
                "com.example.BarTests",
                "com.example.FooTest",
                "com.example.QuxTestCase",
                "com.example.TestBaz",
            ]
        );
    }

    #[test]
    fn test_discover_test_classes_skips_inner_classes() {
        let tmp = TempDir::new().unwrap();
        let pkg = tmp.path().join("com").join("example");
        std::fs::create_dir_all(&pkg).unwrap();
        std::fs::write(pkg.join("FooTest.class"), b"").unwrap();
        std::fs::write(pkg.join("FooTest$Inner.class"), b"").unwrap();

        let classes = discover_test_classes(tmp.path()).unwrap();
        assert_eq!(classes, vec!["com.example.FooTest"]);
    }

    #[test]
    fn test_discover_test_classes_skips_non_test() {
        let tmp = TempDir::new().unwrap();
        let pkg = tmp.path().join("com").join("example");
        std::fs::create_dir_all(&pkg).unwrap();
        std::fs::write(pkg.join("Helper.class"), b"").unwrap();
        std::fs::write(pkg.join("Utils.class"), b"").unwrap();

        let classes = discover_test_classes(tmp.path()).unwrap();
        assert!(classes.is_empty());
    }

    #[test]
    fn test_discover_test_classes_empty_dir() {
        let tmp = TempDir::new().unwrap();
        let classes = discover_test_classes(tmp.path()).unwrap();
        assert!(classes.is_empty());
    }

    #[test]
    fn test_detect_junit_version_junit4() {
        let deps = vec![ResolvedDependency {
            path: PathBuf::from("/repo/junit/junit/4.13.2/junit-4.13.2.jar"),
            group_id: "junit".to_string(),
            artifact_id: "junit".to_string(),
            version: "4.13.2".to_string(),
        }];
        assert_eq!(detect_junit_version(&deps), Some(JunitVersion::Junit4));
    }

    #[test]
    fn test_detect_junit_version_jupiter_alone_returns_none() {
        let deps = vec![ResolvedDependency {
            path: PathBuf::from(
                "/repo/org/junit/jupiter/junit-jupiter/5.10.0/junit-jupiter-5.10.0.jar",
            ),
            group_id: "org.junit.jupiter".to_string(),
            artifact_id: "junit-jupiter".to_string(),
            version: "5.10.0".to_string(),
        }];
        assert_eq!(detect_junit_version(&deps), None);
    }

    #[test]
    fn test_detect_junit_version_junit5_platform() {
        let deps = vec![ResolvedDependency {
            path: PathBuf::from("/repo/org/junit/platform/junit-platform-console-standalone/1.10.0/junit-platform-console-standalone-1.10.0.jar"),
            group_id: "org.junit.platform".to_string(),
            artifact_id: "junit-platform-console-standalone".to_string(),
            version: "1.10.0".to_string(),
        }];
        assert_eq!(detect_junit_version(&deps), Some(JunitVersion::Platform));
    }

    #[test]
    fn test_detect_junit_version_prefers_platform_over_junit4() {
        let deps = vec![
            ResolvedDependency {
                path: PathBuf::from("/repo/junit/junit/4.13.2/junit-4.13.2.jar"),
                group_id: "junit".to_string(),
                artifact_id: "junit".to_string(),
                version: "4.13.2".to_string(),
            },
            ResolvedDependency {
                path: PathBuf::from(
                    "/repo/org/junit/platform/junit-platform-console-standalone/1.10.0/junit-platform-console-standalone-1.10.0.jar",
                ),
                group_id: "org.junit.platform".to_string(),
                artifact_id: "junit-platform-console-standalone".to_string(),
                version: "1.10.0".to_string(),
            },
        ];
        assert_eq!(detect_junit_version(&deps), Some(JunitVersion::Platform));
    }

    #[test]
    fn test_detect_junit_version_vintage_engine_alone_returns_none() {
        let deps = vec![ResolvedDependency {
            path: PathBuf::from(
                "/repo/org/junit/vintage/junit-vintage-engine/5.10.0/junit-vintage-engine-5.10.0.jar",
            ),
            group_id: "org.junit.vintage".to_string(),
            artifact_id: "junit-vintage-engine".to_string(),
            version: "5.10.0".to_string(),
        }];
        assert_eq!(detect_junit_version(&deps), None);
    }

    #[test]
    fn test_detect_junit_version_platform_console_non_standalone() {
        let deps = vec![ResolvedDependency {
            path: PathBuf::from(
                "/repo/org/junit/platform/junit-platform-console/1.10.0/junit-platform-console-1.10.0.jar",
            ),
            group_id: "org.junit.platform".to_string(),
            artifact_id: "junit-platform-console".to_string(),
            version: "1.10.0".to_string(),
        }];
        assert_eq!(detect_junit_version(&deps), Some(JunitVersion::Platform));
    }

    #[test]
    fn test_detect_junit_version_none() {
        let deps = vec![ResolvedDependency {
            path: PathBuf::from("/repo/com/example/lib/1.0/lib-1.0.jar"),
            group_id: "com.example".to_string(),
            artifact_id: "lib".to_string(),
            version: "1.0".to_string(),
        }];
        assert_eq!(detect_junit_version(&deps), None);
    }

    #[test]
    fn test_detect_junit_version_empty_deps() {
        assert_eq!(detect_junit_version(&[]), None);
    }

    #[test]
    fn test_build_classpath_order() {
        let test_dir = Path::new("/project/target/test-classes");
        let classes_dir = Path::new("/project/target/classes");
        let deps = vec![ResolvedDependency {
            path: PathBuf::from("/repo/junit/junit/4.13.2/junit-4.13.2.jar"),
            group_id: "junit".to_string(),
            artifact_id: "junit".to_string(),
            version: "4.13.2".to_string(),
        }];

        let cp = build_classpath(test_dir, classes_dir, &deps);
        let sep = if cfg!(windows) { ";" } else { ":" };
        let parts: Vec<&str> = cp.split(sep).collect();

        assert_eq!(parts.len(), 3);
        assert_eq!(parts[0], test_dir.to_string_lossy());
        assert_eq!(parts[1], classes_dir.to_string_lossy());
        assert_eq!(parts[2], deps[0].path.to_string_lossy());
    }

    /// Create a temporary stub `java` script that exits with the given code
    /// and records all arguments to an `argv` file in the same directory.
    ///
    /// Returns a [`TempDir`] whose path should be prepended to `PATH`.
    /// Read `<dir>/argv` after invocation to inspect the arguments that were
    /// passed.
    #[cfg(unix)]
    fn stub_java(exit_code: i32) -> TempDir {
        use std::os::unix::fs::PermissionsExt;
        let bin = TempDir::new().unwrap();
        let java = bin.path().join("java");
        let argv_path = bin.path().join("argv");
        std::fs::write(
            &java,
            format!(
                "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"{argv}\"\nexit {exit_code}\n",
                argv = argv_path.display(),
                exit_code = exit_code,
            ),
        )
        .unwrap();
        std::fs::set_permissions(&java, std::fs::Permissions::from_mode(0o755)).unwrap();
        bin
    }

    /// Read the recorded argv lines from the stub `java` script.
    #[cfg(unix)]
    fn read_stub_argv(bin: &TempDir) -> Vec<String> {
        let argv_path = bin.path().join("argv");
        std::fs::read_to_string(argv_path)
            .unwrap()
            .lines()
            .map(String::from)
            .collect()
    }

    /// Serializes tests that modify `PATH` to avoid data races.
    #[cfg(unix)]
    static PATH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());

    /// RAII guard that restores `PATH` in its `Drop` impl.
    #[cfg(unix)]
    struct PathGuard {
        original: Option<std::ffi::OsString>,
    }

    #[cfg(unix)]
    impl PathGuard {
        fn prepend(dir: &std::path::Path) -> Self {
            let original = std::env::var_os("PATH");
            let mut paths: Vec<std::path::PathBuf> = vec![dir.to_path_buf()];
            if let Some(prev) = &original {
                paths.extend(std::env::split_paths(prev));
            }
            match std::env::join_paths(&paths) {
                Ok(joined) => std::env::set_var("PATH", &joined),
                Err(_) => std::env::set_var("PATH", dir),
            }
            Self { original }
        }
    }

    #[cfg(unix)]
    impl Drop for PathGuard {
        fn drop(&mut self) {
            match &self.original {
                Some(val) => std::env::set_var("PATH", val),
                None => std::env::remove_var("PATH"),
            }
        }
    }

    #[cfg(unix)]
    #[test]
    fn test_run_junit4_success_with_stub() {
        let _lock = PATH_LOCK.lock().unwrap();
        let bin = stub_java(0);
        let _guard = PathGuard::prepend(bin.path());

        let result = run_junit4(".", &["com.example.FooTest".to_string()]);
        assert!(result.is_ok());

        let argv = read_stub_argv(&bin);
        assert!(
            argv.contains(&"org.junit.runner.JUnitCore".to_string()),
            "expected JUnitCore main class in argv: {argv:?}"
        );
        assert!(
            argv.contains(&"com.example.FooTest".to_string()),
            "expected test class in argv: {argv:?}"
        );
    }

    #[cfg(unix)]
    #[test]
    fn test_run_junit4_failure_with_stub() {
        let _lock = PATH_LOCK.lock().unwrap();
        let bin = stub_java(1);
        let _guard = PathGuard::prepend(bin.path());

        let result = run_junit4(".", &["com.example.FooTest".to_string()]);
        assert!(result.is_err());
        let err_msg = format!("{:#}", result.unwrap_err());
        assert!(
            err_msg.contains("Tests failed"),
            "unexpected error: {err_msg}"
        );
    }

    #[cfg(unix)]
    #[test]
    fn test_run_junit_platform_success_with_stub() {
        let _lock = PATH_LOCK.lock().unwrap();
        let bin = stub_java(0);
        let _guard = PathGuard::prepend(bin.path());

        let tmp = TempDir::new().unwrap();
        let result = run_junit_platform(".", tmp.path());
        assert!(result.is_ok());

        let argv = read_stub_argv(&bin);
        assert!(
            argv.contains(&"org.junit.platform.console.ConsoleLauncher".to_string()),
            "expected ConsoleLauncher main class in argv: {argv:?}"
        );
        // Verify all Surefire include-classname patterns are present
        for pattern in SUREFIRE_CLASSNAME_PATTERNS {
            assert!(
                argv.contains(&pattern.to_string()),
                "expected --include-classname pattern {pattern} in argv: {argv:?}"
            );
        }
        // Verify inner-class exclusion pattern is present
        assert!(
            argv.contains(&INNER_CLASS_EXCLUSION_PATTERN.to_string()),
            "expected --exclude-classname pattern in argv: {argv:?}"
        );
    }

    #[cfg(unix)]
    #[test]
    fn test_run_junit_platform_failure_with_stub() {
        let _lock = PATH_LOCK.lock().unwrap();
        let bin = stub_java(1);
        let _guard = PathGuard::prepend(bin.path());

        let tmp = TempDir::new().unwrap();
        let result = run_junit_platform(".", tmp.path());
        assert!(result.is_err());
        let err_msg = format!("{:#}", result.unwrap_err());
        assert!(
            err_msg.contains("Tests failed"),
            "unexpected error: {err_msg}"
        );
    }
}
