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

#![allow(clippy::disallowed_types)]

use std::{
    env,
    ffi::CString,
    fs::remove_dir_all,
    ops::Range,
    path::{Path, PathBuf},
    process::{Command, ExitCode},
};

use nix::{
    errno::Errno,
    libc::mkdtemp,
    sys::stat::Mode,
    unistd::{chdir, mkdir},
};
use regex::Regex;
use syd::{err::SydResult, syslog::LogLevel};

use crate::util::shuffle_vec;

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

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

impl TempDir {
    fn new<P: AsRef<Path>>(path: P) -> Self {
        TempDir {
            path: path.as_ref().to_path_buf(),
            keep: false,
        }
    }
}

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)?;

    println!("# syd-test: Welcome to the Machine!");
    println!("# usage: syd-test [-hlq] [<name-regex>|<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() {
            #[allow(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();

    std::env::set_var("RUST_BACKTRACE", "full");
    std::env::set_var("SYD_NO_SYSLOG", "YesPlease");

    // 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 = {
        #[allow(clippy::disallowed_methods)]
        let mut tmp = CString::new(format!(
            "{}/syd_test_XXXXXX",
            env::var("SYD_TEST_TMPDIR").unwrap_or(".".to_string())
        ))
        .unwrap()
        .into_bytes_with_nul();
        let ptr = unsafe { mkdtemp(tmp.as_mut_ptr() as *mut _) };
        if !ptr.is_null() {
            let path = unsafe { std::ffi::CStr::from_ptr(ptr).to_str() }?;
            let path = Path::new(path).canonicalize()?;
            match chdir(&path) {
                Ok(_) => {
                    println!("# Running tests under '{}'.", path.display());
                    println!("# Use SYD_TEST_TMPDIR to override.");
                    env::set_var("HOME", &path);
                    Some(TempDir::new(path))
                }
                Err(error) => {
                    println!("# chdir failed: {error}.");
                    None
                }
            }
        } else {
            println!("# mkdtemp failed: {:?}.", Errno::last());
            None
        }
    };

    let mut test_indices = Vec::new();

    // Step 1: Handle the SYD_TEST environment variable.
    let mut test_env_arg = false;
    #[allow(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) => {
                    let regex = Regex::new(&p)?;
                    for (idx, (name, _)) in TESTS.iter().enumerate() {
                        #[allow(clippy::disallowed_methods)]
                        let name = name.strip_prefix("test_syd_").unwrap();
                        if regex.is_match(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) => {
                let regex = Regex::new(&p)?;
                for (idx, (name, _)) in TESTS.iter().enumerate() {
                    #[allow(clippy::disallowed_methods)]
                    let name = name.strip_prefix("test_syd_").unwrap();
                    if regex.is_match(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.
    #[allow(clippy::disallowed_methods)]
    let (seed, seed_set) = match env::var("SYD_TEST_SEED") {
        Err(env::VarError::NotPresent) => {
            let mut buf = vec![0u8; std::mem::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...");
    unsafe { nix::libc::srand(seed) };
    shuffle_vec(&mut test_indices);

    // Print TAP plan.
    if syd::syd_enabled() {
        println!("1..0 # SKIP syd is enabled!");
        return Ok(ExitCode::from(0));
    }
    let ntest = test_indices.len();
    println!("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;
        #[allow(clippy::disallowed_methods)]
        let name = name.strip_prefix("test_syd_").unwrap();
        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");

        #[allow(clippy::disallowed_methods)]
        mkdir(name, Mode::from_bits_truncate(0o700))?;
        #[allow(clippy::disallowed_methods)]
        chdir(name)?;
        let result = test();
        #[allow(clippy::disallowed_methods)]
        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!("ok {idx} - {name} # TODO");
                } else {
                    println!("ok {idx} - {name}");
                }
                let _ = remove_dir_all(name);
            }
            Err(error) => {
                println!("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 = 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))
}
