//! This file will run at build time to autogenerate the WASI regression tests
//! It will compile the files indicated in TESTS, to:executable and .wasm
//! - Compile with the native rust target to get the expected output
//! - Compile with the latest WASI target to get the wasm
//! - Generate the test that will compare the output of running the .wasm file
//!   with wasmer with the expected output

use glob::glob;
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use std::process::Command;

use std::fs::File;
use std::io::prelude::*;
use std::io::BufReader;

static BANNER: &str = "// !!! THIS IS A GENERATED FILE !!!
// ANY MANUAL EDITS MAY BE OVERWRITTEN AT ANY TIME
// Files autogenerated with cargo build (build/wasitests.rs).\n";

pub fn compile(file: &str, ignores: &HashSet<String>) -> Option<String> {
    let mut output_path = PathBuf::from(file);
    output_path.set_extension("out");

    assert!(file.ends_with(".rs"));
    let normalized_name = {
        let mut nn = file.to_lowercase();
        nn.truncate(file.len() - 3);
        nn
    };

    println!("Compiling program {} to native", file);
    let native_out = Command::new("rustc")
        .arg(file)
        .arg("-o")
        .arg(&normalized_name)
        .output()
        .expect("Failed to compile program to native code");
    print_info_on_error(&native_out, "COMPILATION FAILED");

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let normal_path = PathBuf::from(&normalized_name);
        let mut perm = normal_path
            .metadata()
            .expect("native executable")
            .permissions();
        perm.set_mode(0o766);
        fs::set_permissions(normal_path, perm).expect("set permissions");
    }
    let rs_module_name = {
        let temp = PathBuf::from(&normalized_name);
        temp.file_name().unwrap().to_string_lossy().to_string()
    };

    let result = Command::new(&normalized_name)
        .output()
        .expect("Failed to execute native program");
    print_info_on_error(&result, "NATIVE PROGRAM FAILED");

    std::fs::remove_file(&normalized_name).expect("could not delete executable");
    let wasm_out_name = format!("{}.wasm", &normalized_name);

    let mut file_contents = String::new();
    {
        let mut file = std::fs::File::open(file).unwrap();
        file.read_to_string(&mut file_contents).unwrap();
    }
    {
        let mut actual_file = std::fs::OpenOptions::new()
            .write(true)
            .truncate(true)
            .open(file)
            .unwrap();
        actual_file
            .write_all(format!("#![feature(wasi_ext)]\n{}", &file_contents).as_bytes())
            .unwrap();
    }

    let wasm_compilation_out = Command::new("rustc")
        .arg("+nightly")
        .arg("--target=wasm32-wasi")
        .arg("-C")
        .arg("opt-level=s")
        .arg(file)
        .arg("-o")
        .arg(&wasm_out_name)
        .output()
        .expect("Failed to compile program to native code");
    print_info_on_error(&wasm_compilation_out, "WASM COMPILATION");
    {
        let mut actual_file = std::fs::OpenOptions::new()
            .write(true)
            .truncate(true)
            .open(file)
            .unwrap();
        actual_file.write_all(file_contents.as_bytes()).unwrap();
    }

    // to prevent commiting huge binary blobs forever
    let wasm_strip_out = Command::new("wasm-strip")
        .arg(&wasm_out_name)
        .output()
        .expect("Failed to strip compiled wasm module");
    print_info_on_error(&wasm_strip_out, "STRIPPING WASM");

    let ignored = if ignores.contains(&rs_module_name) {
        "\n#[ignore]"
    } else {
        ""
    };

    let src_code = fs::read_to_string(file).expect("read src file");
    let args: Args = extract_args_from_source_file(&src_code).unwrap_or_default();

    let mapdir_args = {
        let mut out_str = String::new();
        out_str.push_str("vec![");
        for (alias, real_dir) in args.mapdir {
            out_str.push_str(&format!(
                "(\"{}\".to_string(), ::std::path::PathBuf::from(\"{}\")),",
                alias, real_dir
            ));
        }
        out_str.push_str("]");
        out_str
    };

    let envvar_args = {
        let mut out_str = String::new();
        out_str.push_str("vec![");

        for entry in args.envvars {
            out_str.push_str(&format!("\"{}={}\".to_string(),", entry.0, entry.1));
        }

        out_str.push_str("]");
        out_str
    };

    let dir_args = {
        let mut out_str = String::new();
        out_str.push_str("vec![");

        for entry in args.po_dirs {
            out_str.push_str(&format!("\"{}\".to_string(),", entry));
        }

        out_str.push_str("]");
        out_str
    };

    let contents = format!(
        "#[test]{ignore}
fn test_{rs_module_name}() {{
    assert_wasi_output!(
        \"../../{module_path}\",
        \"{rs_module_name}\",
        {dir_args},
        {mapdir_args},
        {envvar_args},
        \"../../{test_output_path}\"
    );
}}
",
        ignore = ignored,
        module_path = wasm_out_name,
        rs_module_name = rs_module_name,
        test_output_path = format!("{}.out", normalized_name),
        dir_args = dir_args,
        mapdir_args = mapdir_args,
        envvar_args = envvar_args
    );
    let rust_test_filepath = format!(
        concat!(env!("CARGO_MANIFEST_DIR"), "/tests/{}.rs"),
        normalized_name,
    );
    fs::write(&rust_test_filepath, contents.as_bytes()).expect("writing test file");
    fs::write(&output_path, result.stdout).expect("writing output to file");

    Some(rs_module_name)
}

pub fn build() {
    let rust_test_modpath = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/wasitests/mod.rs");

    let mut modules: Vec<String> = Vec::new();

    let ignores = read_ignore_list();
    for entry in glob("wasitests/*.rs").unwrap() {
        match entry {
            Ok(path) => {
                let test = path.to_str().unwrap();
                if let Some(module_name) = compile(test, &ignores) {
                    modules.push(module_name);
                }
            }
            Err(e) => println!("{:?}", e),
        }
    }
    modules.sort();
    let mut modules: Vec<String> = modules.iter().map(|m| format!("mod {};", m)).collect();
    assert!(modules.len() > 0, "Expected > 0 modules found");

    modules.insert(0, BANNER.to_string());
    modules.insert(1, "// The _common module is not autogenerated.  It provides common macros for the wasitests\n#[macro_use]\nmod _common;".to_string());
    // We add an empty line
    modules.push("".to_string());

    let modfile: String = modules.join("\n");
    let source = fs::read(&rust_test_modpath).unwrap();
    // We only modify the mod file if has changed
    if source != modfile.as_bytes() {
        fs::write(&rust_test_modpath, modfile.as_bytes()).unwrap();
    }
}

fn read_ignore_list() -> HashSet<String> {
    let f = File::open("wasitests/ignores.txt").unwrap();
    let f = BufReader::new(f);
    f.lines()
        .filter_map(Result::ok)
        .map(|v| v.to_lowercase())
        .collect()
}

#[derive(Debug, Default)]
struct Args {
    pub mapdir: Vec<(String, String)>,
    pub envvars: Vec<(String, String)>,
    /// pre-opened directories
    pub po_dirs: Vec<String>,
}

/// Pulls args to the program out of a comment at the top of the file starting with "// Args:"
fn extract_args_from_source_file(source_code: &str) -> Option<Args> {
    if source_code.starts_with("// Args:") {
        let mut args = Args::default();
        for arg_line in source_code
            .lines()
            .skip(1)
            .take_while(|line| line.starts_with("// "))
        {
            let tokenized = arg_line
                .split_whitespace()
                // skip trailing space
                .skip(1)
                .map(String::from)
                .collect::<Vec<String>>();
            let command_name = {
                let mut cn = tokenized[0].clone();
                assert_eq!(
                    cn.pop(),
                    Some(':'),
                    "Final character of argname must be a colon"
                );
                cn
            };

            match command_name.as_ref() {
                "mapdir" => {
                    if let [alias, real_dir] = &tokenized[1].split(':').collect::<Vec<&str>>()[..] {
                        args.mapdir.push((alias.to_string(), real_dir.to_string()));
                    } else {
                        eprintln!(
                            "Parse error in mapdir {} not parsed correctly",
                            &tokenized[1]
                        );
                    }
                }
                "env" => {
                    if let [name, val] = &tokenized[1].split('=').collect::<Vec<&str>>()[..] {
                        args.envvars.push((name.to_string(), val.to_string()));
                    } else {
                        eprintln!("Parse error in env {} not parsed correctly", &tokenized[1]);
                    }
                }
                "dir" => {
                    args.po_dirs.push(tokenized[1].to_string());
                }
                e => {
                    eprintln!("WARN: comment arg: {} is not supported", e);
                }
            }
        }
        return Some(args);
    }
    None
}

fn print_info_on_error(output: &std::process::Output, context: &str) {
    if !output.status.success() {
        println!("{}", context);
        println!(
            "stdout:\n{}",
            std::str::from_utf8(&output.stdout[..]).unwrap()
        );
        eprintln!(
            "stderr:\n{}",
            std::str::from_utf8(&output.stderr[..]).unwrap()
        );
    }
}