//
// Syd: rock-solid application kernel
// src/test/main.rs: Run integration tests with TAP output
//
// Copyright (c) 2023, 2024, 2025, 2026 Ali Polatel <alip@chesswob.org>
//
// SPDX-License-Identifier: GPL-3.0

use std::{
    env,
    fs::remove_dir_all,
    ops::Range,
    process::{Command, ExitCode},
};

use nix::{
    errno::Errno,
    fcntl::AT_FDCWD,
    sys::stat::{fchmodat, umask, FchmodatFlags, Mode},
    unistd::{chdir, getcwd, mkdir, mkdtemp},
};
use syd::{
    err::SydResult,
    path::{XPath, XPathBuf},
    syslog::LogLevel,
    wildmatch::inamematch,
};

use crate::util::shuffle_vec;

mod test;
mod util;
use test::*;

// Set global allocator to GrapheneOS allocator.
#[cfg(all(
    not(coverage),
    not(feature = "prof"),
    not(target_os = "android"),
    not(target_arch = "riscv64"),
    target_page_size_4k,
    target_pointer_width = "64"
))]
#[global_allocator]
static GLOBAL: hardened_malloc::HardenedMalloc = hardened_malloc::HardenedMalloc;

// Set global allocator to tcmalloc if profiling is enabled.
#[cfg(feature = "prof")]
#[global_allocator]
static GLOBAL: tcmalloc::TCMalloc = tcmalloc::TCMalloc;

#[derive(Debug)]
struct TempDir {
    path: XPathBuf,
    keep: bool,
}

impl TempDir {
    fn new(path: &XPath) -> Self {
        TempDir {
            path: path.to_owned(),
            keep: std::env::var_os("SYD_TEST_KEEP").is_some(),
        }
    }
}

impl Drop for TempDir {
    fn drop(&mut self) {
        if self.keep {
            return;
        }

        match Command::new("rm").arg("-rf").arg(&self.path).status() {
            Ok(status) => {
                if !status.success() {
                    let path = self.path.display();
                    eprintln!(
                        "Failed to remove temporary directory \"{path}\": rm returned non-zero."
                    );
                }
            }
            Err(error) => {
                let path = self.path.display();
                eprintln!("Failed to remove temporary directory \"{path}\": {error}.");
            }
        };
    }
}

#[derive(Debug)]
enum Arguments {
    Index(usize),
    Range(Range<usize>),
    Pattern(String),
}

struct ArgVec(Vec<Arguments>);

impl From<String> for Arguments {
    fn from(arg: String) -> Self {
        if let Ok(idx) = arg.parse::<usize>() {
            Arguments::Index(idx)
        } else if let Some(range) = arg.split_once("..") {
            if let (Ok(start), Ok(end)) = (range.0.parse::<usize>(), range.1.parse::<usize>()) {
                Arguments::Range(start..end)
            } else {
                Arguments::Pattern(arg)
            }
        } else {
            Arguments::Pattern(arg)
        }
    }
}

impl From<String> for ArgVec {
    fn from(arg: String) -> Self {
        if let Ok(idx) = arg.parse::<usize>() {
            ArgVec(vec![Arguments::Index(idx)])
        } else if let Some(range) = arg.split_once("..") {
            if let (Ok(start), Ok(end)) = (range.0.parse::<usize>(), range.1.parse::<usize>()) {
                ArgVec(vec![Arguments::Range(start..end)])
            } else {
                ArgVec(vec![Arguments::Pattern(arg)])
            }
        } else {
            ArgVec(vec![Arguments::Pattern(arg)])
        }
    }
}

fn main() -> SydResult<ExitCode> {
    syd::set_sigpipe_dfl()?;

    // Initialize logging.
    syd::log::log_init_simple(LogLevel::Warn)?;

    // Set a sane umask.
    umask(Mode::from_bits_truncate(0o077));

    println!("# syd-test: Welcome to the Machine!");
    println!("# usage: syd-test [-hlq] [<name-glob>|<number>|<number>..<number>]..");

    let args = std::env::args().skip(1).collect::<Vec<_>>();
    let mut args_is_empty = args.is_empty();
    let mut skip_args = 0;

    if !args_is_empty && matches!(args[0].as_str(), "-h" | "--help" | "-l" | "--list") {
        for (idx, (name, _)) in TESTS.iter().enumerate() {
            #[expect(clippy::disallowed_methods)]
            let name = name.strip_prefix("test_syd_").unwrap();
            let idx = idx + 1;
            println!("{idx:>3}: {name}");
        }
        return Ok(ExitCode::SUCCESS);
    }

    let fail_quick = !args_is_empty && matches!(args[0].as_str(), "-q" | "--quick");
    if fail_quick {
        args_is_empty = args.len() == 1;
        skip_args = 1;
    }
    let fail_quick = fail_quick || std::env::var_os("SYD_TEST_QUICK").is_some();

    // Check if the running terminal supports
    // terminal titles using `tput tsl`.
    let tsl = Command::new("tput")
        .arg("tsl")
        .status()
        .map(|s| s.success())
        .unwrap_or(false);

    // Create a temporary directory and enter it, failures are OK.
    // The directory is removed when the guard is dropped.
    let tmpdir = {
        #[expect(clippy::disallowed_methods)]
        let tmp = format!(
            "{}/syd_test_XXXXXX",
            env::var("SYD_TEST_TMPDIR").unwrap_or(".".to_string())
        );
        match mkdtemp(XPath::new(&tmp)) {
            Ok(path) => {
                // HOME must be canonicalized!
                let path = path.canonicalize().map(XPathBuf::from)?;

                match chdir(&path) {
                    Ok(_) => {
                        println!("# Running tests under '{}'.", path.display());
                        println!("# Use SYD_TEST_TMPDIR to override.");
                        //Setting HOME here breaks podman!
                        //env::set_var("HOME", &path);
                        Some(TempDir::new(&path))
                    }
                    Err(error) => {
                        println!("# chdir failed: {error}.");
                        None
                    }
                }
            }
            Err(errno) => {
                println!("# mkdtemp failed: {errno}.");
                None
            }
        }
    };

    let mut test_indices = Vec::new();

    // Step 1: Handle the SYD_TEST environment variable.
    let mut test_env_arg = false;
    #[expect(clippy::disallowed_methods)]
    if let Ok(env) = std::env::var("SYD_TEST") {
        if !env.is_empty() {
            test_env_arg = true;

            let arg: Arguments = env.into();
            match arg {
                Arguments::Index(i) => test_indices.push(i),
                Arguments::Range(r) => test_indices.extend(r),
                Arguments::Pattern(p) => {
                    for (idx, (name, _)) in TESTS.iter().enumerate() {
                        #[expect(clippy::disallowed_methods)]
                        let name = name.strip_prefix("test_syd_").unwrap();
                        if inamematch(&p, name) {
                            test_indices.push(idx + 1);
                            if p.to_ascii_lowercase().contains("exp") && name.starts_with("exp_") {
                                env::set_var("SYD_TEST_EXPENSIVE", "1");
                            }
                        }
                    }
                }
            }
        }
    }

    // Step 2: Handle command line arguments.
    let args: Vec<Arguments> = args
        .into_iter()
        .skip(skip_args)
        .map(ArgVec::from)
        .flat_map(|arg_vec| arg_vec.0)
        .collect();
    for arg in args {
        match arg {
            Arguments::Index(i) => test_indices.push(i),
            Arguments::Range(r) => test_indices.extend(r),
            Arguments::Pattern(p) => {
                for (idx, (name, _)) in TESTS.iter().enumerate() {
                    #[expect(clippy::disallowed_methods)]
                    let name = name.strip_prefix("test_syd_").unwrap();
                    if inamematch(&p, name) {
                        test_indices.push(idx + 1);
                        if p.to_ascii_lowercase().contains("exp") && name.starts_with("exp_") {
                            env::set_var("SYD_TEST_EXPENSIVE", "1");
                        }
                    }
                }
            }
        }
    }

    // If SYD_TEST was not set and no arguments are provided,
    // run all tests.
    if !test_env_arg && args_is_empty {
        test_indices.extend(1..=TESTS.len());
    }

    // Shuffle test indices with an optional seed.
    #[expect(clippy::disallowed_methods)]
    let (seed, seed_set) = match env::var("SYD_TEST_SEED") {
        Err(env::VarError::NotPresent) => {
            let mut buf = vec![0u8; size_of::<nix::libc::c_uint>()];
            let ret = unsafe {
                nix::libc::getrandom(buf.as_mut_ptr().cast(), buf.len(), nix::libc::GRND_RANDOM)
            };
            if ret == buf.len() as nix::libc::ssize_t {
                let ret = nix::libc::c_uint::from_ne_bytes(buf[..4].try_into()?);
                eprintln!("# Determined test seed using /dev/random.");
                (ret, false)
            } else {
                eprintln!(
                    "# getrandom failed ({}), using default seed...",
                    Errno::last()
                );
                (31415926, false)
            }
        }
        Ok(val) => match val.parse::<nix::libc::c_uint>() {
            Ok(val) => (val, true),
            Err(error) => {
                eprintln!("# Invalid test seed: {error}!");
                return Ok(ExitCode::from(1));
            }
        },
        Err(error) => {
            eprintln!("# Invalid test seed: {error}!");
            return Ok(ExitCode::from(1));
        }
    };
    if seed_set {
        eprintln!("# Test seed: {seed}, manually set with SYD_TEST_SEED.");
    } else {
        eprintln!("# Test seed: {seed}, set SYD_TEST_SEED to override.");
    }
    eprintln!("# Shuffling tests using the Fischer-Yates algorithm...");
    #[cfg(not(target_os = "android"))]
    unsafe {
        libc::srand(seed)
    };
    shuffle_vec(&mut test_indices);

    // Print TAP plan.
    let ntest = test_indices.len();
    let ptest = if env::var_os("SYD_TEST_REEXEC").is_none() {
        ""
    } else {
        "#    "
    };
    println!("{ptest}1..{ntest}");

    let exp_test = env::var_os("SYD_TEST_EXPENSIVE").is_some();
    let mut fail_hard = 0;
    let mut fail_soft = 0;
    let mut skip = 0;
    let mut fail_names = Vec::new();
    let mut skip_names = Vec::new();
    let mut soft_fails = Vec::new();
    let mut idx = 0;
    let mut rtest = 0;
    for &test_idx in test_indices.iter() {
        let (name, test) = if let Some((name, test)) = TESTS.get(test_idx - 1) {
            (name, test)
        } else {
            continue;
        };
        idx += 1;
        #[expect(clippy::disallowed_methods)]
        let name = name.strip_prefix("test_syd_").unwrap();
        env::set_var("SYD_TEST_NAME", name);
        if exp_test && !name.starts_with("exp_") {
            println!("# ok {idx} - {name} # SKIP not expensive, unset SYD_TEST_EXPENSIVE to run");
            skip += 1;
            skip_names.push(name.to_string());
            continue;
        } else if !exp_test && name.starts_with("exp_") {
            println!("# ok {idx} - {name} # SKIP expensive test, set SYD_TEST_EXPENSIVE to run");
            skip += 1;
            skip_names.push(name.to_string());
            continue;
        }
        let status = format!(
            "{name} ({idx} of {ntest}: {} ok, {} notok, {} todo, {} left)",
            idx - fail_hard - fail_soft - skip - 1,
            fail_hard,
            fail_soft + skip,
            ntest - idx,
        );
        if tsl {
            print!("\x1b]0;syd-test: {status}\x07");
        }
        println!("\x1b[92m*** {status} ***\x1b[0m");

        // Note, the extra chmod is needed to
        // drop the setgid bits we inherit from
        // the parent directory, otherwise some
        // tests might fail, e.g:
        // https://builds.sr.ht/~alip/job/1543770
        mkdir(name, Mode::from_bits_truncate(0o700))?;
        fchmodat(
            AT_FDCWD,
            name,
            Mode::from_bits_truncate(0o700),
            FchmodatFlags::FollowSymlink,
        )?;
        chdir(name)?;
        let cwd = std::env::current_dir()?.canonicalize()?;
        std::env::set_var("PWD", &cwd);
        std::env::set_var("TMP", &cwd);
        std::env::set_var("TMPDIR", &cwd);
        let result = test();
        chdir("..")?;
        rtest += 1;
        match result {
            Ok(_) => {
                if std::env::var_os("SYD_TEST_SOFT_FAIL").is_some() {
                    fail_soft += 1;
                    soft_fails.push(name.to_string());
                    std::env::remove_var("SYD_TEST_SOFT_FAIL");
                    println!("{ptest}ok {idx} - {name} # TODO");
                } else {
                    println!("{ptest}ok {idx} - {name}");
                }
                if tmpdir.as_ref().map(|t| t.keep).unwrap_or(false) {
                    let cwd = getcwd()
                        .map(XPathBuf::from)
                        .unwrap_or_else(|_| XPathBuf::from("?"));
                    eprintln!("# Keeping test directory \"{cwd}\"");
                } else {
                    let _ = remove_dir_all(name);
                }
            }
            Err(error) => {
                println!("{ptest}not ok {idx} - {name} - FAIL: {error}");
                fail_hard += 1;
                fail_names.push(name.to_string());
                if fail_quick {
                    break;
                }
            }
        }
    }

    let succ = rtest - fail_hard.saturating_sub(fail_soft).saturating_sub(skip);
    println!("# {succ} tests passed.");
    println!("# {skip} tests skipped.");
    if fail_soft > 0 {
        soft_fails.sort();
        println!("# {fail_soft} tests failed soft, aka known failures:");
        for (index, test) in soft_fails.iter().enumerate() {
            println!("#     {}. {}", index + 1, test);
        }
    } else {
        println!("# {fail_soft} tests failed soft. No known failures! \\o/");
    }
    if fail_hard > 0 {
        fail_names.sort();
        println!("# {fail_hard} tests failed hard, aka breaking failures:");
        for (index, test) in fail_names.iter().enumerate() {
            println!("#     {}. {}", index + 1, test);
        }
    } else {
        println!("# {fail_hard} tests failed hard. No breaking failures! \\o/");
    }
    let code = if cfg!(coverage) {
        0
    } else {
        fail_hard.try_into().unwrap_or(127)
    };
    if code != 0 {
        // Keep the temporary directory for further inspection.
        if let Some(mut tmpdir) = tmpdir {
            tmpdir.keep = true;
            let tmpdir = tmpdir.path.display();
            eprintln!("Keeping temporary test directory \"{tmpdir}\".");
        }
    }
    Ok(ExitCode::from(code))
}
