#![allow(dead_code)]

use core::fmt;
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;

use tempfile::TempDir;

#[macro_use]
#[path = "../build/macros.rs"]
mod macros;

#[path = "../build/common.rs"]
mod common;
#[path = "../build/dynamic.rs"]
mod dynamic;
#[path = "../build/static.rs"]
mod r#static;

#[derive(Debug, Default)]
struct RunCommandMock {
    invocations: Vec<(String, String, Vec<String>)>,
    responses: HashMap<Vec<String>, String>,
}


#[derive(Copy, Clone, Debug)]
enum Arch {
    ARM64,
    X86,
    X86_64,
}

impl Arch {
    fn pe_machine_type(self) -> u16 {
        match self {
            Arch::ARM64 => 0xAA64,
            Arch::X86 => 0x014C,
            Arch::X86_64 => 0x8664,
        }
    }
}

impl fmt::Display for Arch {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Arch::ARM64 => write!(f, "aarch64"),
            Arch::X86 => write!(f, "x86"),
            Arch::X86_64 => write!(f, "x86_64"),
        }
    }
}

#[derive(Debug)]
struct Env {
    os: String,
    arch: Arch,
    pointer_width: String,
    env: Option<String>,
    vars: HashMap<String, (Option<String>, Option<String>)>,
    cwd: PathBuf,
    tmp: TempDir,
    files: Vec<String>,
    commands: Arc<Mutex<RunCommandMock>>,
}

impl Env {
    fn new(os: &str, arch: Arch, pointer_width: &str) -> Self {
        Env {
            os: os.into(),
            arch,
            pointer_width: pointer_width.into(),
            env: None,
            vars: HashMap::new(),
            cwd: env::current_dir().unwrap(),
            tmp: tempfile::Builder::new().prefix("clang_sys_test").tempdir().unwrap(),
            files: vec![],
            commands: Default::default(),
        }
        .var("CLANG_PATH", None)
        .var("LD_LIBRARY_PATH", None)
        .var("LIBCLANG_PATH", None)
        .var("LIBCLANG_STATIC_PATH", None)
        .var("LLVM_CONFIG_PATH", None)
        .var("PATH", None)
    }

    fn env(mut self, env: &str) -> Self {
        self.env = Some(env.into());
        self
    }

    fn var(mut self, name: &str, value: Option<&str>) -> Self {
        let previous = env::var(name).ok();
        self.vars.insert(name.into(), (value.map(|v| v.into()), previous));
        self
    }

    fn dir(mut self, path: &str) -> Self {
        self.files.push(path.into());
        let path = self.tmp.path().join(path);
        fs::create_dir_all(path).unwrap();
        self
    }

    fn file(mut self, path: &str, contents: &[u8]) -> Self {
        self.files.push(path.into());
        let path = self.tmp.path().join(path);
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(self.tmp.path().join(path), contents).unwrap();
        self
    }

    fn dll(self, path: &str, arch: Arch, pointer_width: &str) -> Self {
        // PE header.
        let mut contents = [0; 64];
        contents[0x3C..0x3C + 4].copy_from_slice(&i32::to_le_bytes(10));
        contents[10..14].copy_from_slice(&[b'P', b'E', 0, 0]);
        contents[14..16].copy_from_slice(&u16::to_le_bytes(arch.pe_machine_type()));
        let magic = if pointer_width == "64" { 523 } else { 267 };
        contents[34..36].copy_from_slice(&u16::to_le_bytes(magic));

        self.file(path, &contents)
    }

    fn so(self, path: &str, pointer_width: &str) -> Self {
        // ELF header.
        let class = if pointer_width == "64" { 2 } else { 1 };
        let contents = [127, 69, 76, 70, class];

        self.file(path, &contents)
    }

    fn command(self, command: &str, args: &[&str], response: &str) -> Self {
        let command = command.to_string();
        let args = args.iter().map(|a| a.to_string()).collect::<Vec<_>>();

        let mut key = vec![command];
        key.extend(args);
        self.commands.lock().unwrap().responses.insert(key, response.into());

        self
    }

    fn enable(self) -> Self {
        env::set_var("_CLANG_SYS_TEST", "yep");
        env::set_var("_CLANG_SYS_TEST_OS", &self.os);
        env::set_var("_CLANG_SYS_TEST_ARCH", &format!("{}", self.arch));
        env::set_var("_CLANG_SYS_TEST_POINTER_WIDTH", &self.pointer_width);
        if let Some(env) = &self.env {
            env::set_var("_CLANG_SYS_TEST_ENV", env);
        }

        for (name, (value, _)) in &self.vars {
            if let Some(value) = value {
                env::set_var(name, value);
            } else {
                env::remove_var(name);
            }
        }

        env::set_current_dir(&self.tmp).unwrap();

        let commands = self.commands.clone();
        let mock = &mut *common::RUN_COMMAND_MOCK.lock().unwrap();
        *mock = Some(Box::new(move |command, path, args| {
            let command = command.to_string();
            let path = path.to_string();
            let args = args.iter().map(|a| a.to_string()).collect::<Vec<_>>();

            let mut commands = commands.lock().unwrap();
            commands.invocations.push((command.clone(), path, args.clone()));

            let mut key = vec![command];
            key.extend(args);
            commands.responses.get(&key).cloned()
        }));

        self
    }
}

impl Drop for Env {
    fn drop(&mut self) {
        env::remove_var("_CLANG_SYS_TEST");
        env::remove_var("_CLANG_SYS_TEST_OS");
        env::remove_var("_CLANG_SYS_TEST_ARCH");
        env::remove_var("_CLANG_SYS_TEST_POINTER_WIDTH");
        env::remove_var("_CLANG_SYS_TEST_ENV");

        for (name, (_, previous)) in &self.vars {
            if let Some(previous) = previous {
                env::set_var(name, previous);
            } else {
                env::remove_var(name);
            }
        }

        if let Err(error) = env::set_current_dir(&self.cwd) {
            println!("Failed to reset working directory: {:?}", error);
        }
    }
}

#[test]
fn test_all() {
    // Run tests serially since they alter the environment.
    
    test_linux_directory_preference();
    test_linux_version_preference();
    test_linux_directory_and_version_preference();

    #[cfg(target_os = "windows")]
    {
        test_windows_bin_sibling();
        test_windows_mingw_gnu();
        test_windows_mingw_msvc();
        test_windows_arm64_on_x86_64();
        test_windows_x86_64_on_arm64();
    }
}

macro_rules! assert_error {
    ($result:expr, $contents:expr $(,)?) => {
        if let Err(error) = $result {
            if !error.contains($contents) {
                panic!("expected error to contain {:?}, received: {error:?}", $contents);
            }
        } else {
            panic!("expected error, received: {:?}", $result);
        }
    };
}

//================================================
// Dynamic
//================================================

// Linux -----------------------------------------

fn test_linux_directory_preference() {
    let _env = Env::new("linux", Arch::X86_64, "64")
        .so("usr/lib/libclang.so.1", "64")
        .so("usr/local/lib/libclang.so.1", "64")
        .enable();

    assert_eq!(
        dynamic::find(true),
        Ok(("usr/local/lib".into(), "libclang.so.1".into())),
    );
}

fn test_linux_version_preference() {
    let _env = Env::new("linux", Arch::X86_64, "64")
        .so("usr/lib/libclang-3.so", "64")
        .so("usr/lib/libclang-3.5.so", "64")
        .so("usr/lib/libclang-3.5.0.so", "64")
        .enable();

    assert_eq!(
        dynamic::find(true),
        Ok(("usr/lib".into(), "libclang-3.5.0.so".into())),
    );
}

fn test_linux_directory_and_version_preference() {
    let _env = Env::new("linux", Arch::X86_64, "64")
        .so("usr/local/llvm/lib/libclang-3.so", "64")
        .so("usr/local/lib/libclang-3.5.so", "64")
        .so("usr/lib/libclang-3.5.0.so", "64")
        .enable();

    assert_eq!(
        dynamic::find(true),
        Ok(("usr/lib".into(), "libclang-3.5.0.so".into())),
    );
}

// Windows ---------------------------------------

#[cfg(target_os = "windows")]
fn test_windows_bin_sibling() {
    let _env = Env::new("windows", Arch::X86_64, "64")
        .dir("Program Files\\LLVM\\lib")
        .dll("Program Files\\LLVM\\bin\\libclang.dll", Arch::X86_64, "64")
        .enable();

    assert_eq!(
        dynamic::find(true),
        Ok(("Program Files\\LLVM\\bin".into(), "libclang.dll".into())),
    );
}

#[cfg(target_os = "windows")]
fn test_windows_mingw_gnu() {
    let _env = Env::new("windows", Arch::X86_64, "64")
        .env("gnu")
        .dir("MSYS\\MinGW\\lib")
        .dll("MSYS\\MinGW\\bin\\clang.dll", Arch::X86_64, "64")
        .dir("Program Files\\LLVM\\lib")
        .dll("Program Files\\LLVM\\bin\\libclang.dll", Arch::X86_64, "64")
        .enable();

    assert_eq!(
        dynamic::find(true),
        Ok(("MSYS\\MinGW\\bin".into(), "clang.dll".into())),
    );
}

#[cfg(target_os = "windows")]
fn test_windows_mingw_msvc() {
    let _env = Env::new("windows", Arch::X86_64, "64")
        .env("msvc")
        .dir("MSYS\\MinGW\\lib")
        .dll("MSYS\\MinGW\\bin\\clang.dll", Arch::X86_64, "64")
        .dir("Program Files\\LLVM\\lib")
        .dll("Program Files\\LLVM\\bin\\libclang.dll", Arch::X86_64, "64")
        .enable();

    assert_eq!(
        dynamic::find(true),
        Ok(("Program Files\\LLVM\\bin".into(), "libclang.dll".into())),
    );
}

#[cfg(target_os = "windows")]
fn test_windows_arm64_on_x86_64() {
    let _env = Env::new("windows", Arch::X86_64, "64")
        .env("msvc")
        .dir("Program Files\\LLVM\\lib")
        .dll("Program Files\\LLVM\\bin\\libclang.dll", Arch::ARM64, "64")
        .enable();

    assert_error!(
        dynamic::find(true), 
        "invalid: [(Program Files\\LLVM\\bin\\libclang.dll: invalid DLL (ARM64)",
    );
}

#[cfg(target_os = "windows")]
fn test_windows_x86_64_on_arm64() {
    let _env = Env::new("windows", Arch::ARM64, "64")
        .env("msvc")
        .dir("Program Files\\LLVM\\lib")
        .dll("Program Files\\LLVM\\bin\\libclang.dll", Arch::X86_64, "64")
        .enable();

    assert_error!(
        dynamic::find(true), 
        "invalid: [(Program Files\\LLVM\\bin\\libclang.dll: invalid DLL (x86-64)",
    );
}
