use crate::{
    config::Config,
    fs,
    lua_installation::LuaInstallation,
    lua_rockspec::{DeploySpec, LuaModule, ModulePaths},
    path::{Paths, PathsError},
    project::project_files,
    tree::{InstallTree, RockLayout},
    variables::{self, Environment, VariableSubstitutionError},
};
use itertools::Itertools;
use miette::Diagnostic;
use path_slash::PathExt;
use shlex::try_quote;
use std::{
    collections::HashMap,
    io,
    path::{Path, PathBuf},
    process::{ExitStatus, Output, Stdio},
    string::FromUtf8Error,
};
use target_lexicon::Triple;
use thiserror::Error;
use tokio::process::Command;
use tracing::span;
use which::which;

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

use super::external_dependency::ExternalDependencyInfo;

pub(crate) trait CCExt {
    fn try_compile_objects(&self, config: &Config) -> Result<Vec<PathBuf>, cc::Error>;
}

impl CCExt for cc::Build {
    fn try_compile_objects(&self, config: &Config) -> Result<Vec<PathBuf>, cc::Error> {
        if config.verbose() {
            self.try_compile_intermediates()
        } else {
            // HACK: try_compile_intermediates prints the exit code to stderr,
            // which interferes with lux-cli progress spinners, etc
            let mut _stdout_gag = gag::Gag::stdout();
            let mut _stderr_gag = gag::Gag::stderr();
            self.try_compile_intermediates()
        }
    }
}

/// Copies a lua source file to a specific destination. The destination is described by a
/// `module.path` syntax (equivalent to the syntax provided to Lua's `require()` function).
pub(crate) fn copy_lua_to_module_path(
    source: &PathBuf,
    target_module: &LuaModule,
    target_dir: &Path,
) -> Result<(), fs::FsError> {
    let span = span!(
        tracing::Level::TRACE,
        "Copying Lua module",
        source = source.to_slash_lossy().to_string(),
        target = target_module.to_string(),
        target_dir = target_dir.to_slash_lossy().to_string(),
    );
    let _enter = span.enter();
    // Some rockspecs abuse `install.lua` to install non-lua files, such as teal (`.tl`) files.
    let target_module_path = source
        .extension()
        .map(|ext| target_module.to_file_path(&format!(".{}", ext.to_string_lossy().as_ref())))
        .unwrap_or_else(|| target_module.to_lua_path());
    let target = target_dir.join(target_module_path);

    if let Some(parent) = target.parent() {
        fs::sync::create_dir_all(parent)?;
    }

    fs::sync::copy(source, &target)?;

    Ok(())
}

/// Recursively copy a directory.
/// This respects ignore files and excludes hidden files and directories.
#[tracing::instrument(level = "trace")]
pub(crate) async fn recursive_copy_dir(src: &PathBuf, dest: &Path) -> Result<(), fs::FsError> {
    if src.exists() {
        for file in project_files(src) {
            let relative_src_path: PathBuf = pathdiff::diff_paths(src.join(&file), src)
                .unwrap_or_else(|| unreachable!("diff_path with self"));
            let target = dest.join(relative_src_path);
            if let Some(parent) = target.parent() {
                fs::tokio::create_dir_all(parent).await?;
            }
            fs::tokio::copy(&file, target).await?;
        }
    }
    Ok(())
}

#[derive(Error, Debug, Diagnostic)]
#[non_exhaustive]
pub enum OutputValidationError {
    #[error("compilation failed.\nstatus: {status}\nstdout: {stdout}\nstderr: {stderr}")]
    #[diagnostic(help(
        r#"check the compiler output above for details.
rerun with `--verbose` for more information."#
    ))]
    CommandFailure {
        status: ExitStatus,
        stdout: String,
        stderr: String,
    },
}

fn validate_output(output: &Output) -> Result<(), OutputValidationError> {
    if !output.status.success() {
        return Err(OutputValidationError::CommandFailure {
            status: output.status,
            stdout: String::from_utf8_lossy(&output.stdout).into(),
            stderr: String::from_utf8_lossy(&output.stderr).into(),
        });
    }
    Ok(())
}

#[derive(Error, Debug, Diagnostic)]
#[non_exhaustive]
pub enum CompileCFilesError {
    #[error(transparent)]
    #[diagnostic(transparent)]
    Fs(#[from] fs::FsError),
    #[error("failed to compile intermediates from C files")]
    #[diagnostic(help(
        r#"check the compiler output above for missing headers or syntax errors.
ensure the C compiler has access to the required include paths."#
    ))]
    CompileIntermediates { source: cc::Error },
    #[error("error compiling C files (compilation failed):")]
    #[diagnostic(help(
        r#"check the compiler output above for details.
if no C compiler was found, run `lx debug toolchains` to verify your build tools."#
    ))]
    Compilation(#[from] cc::Error),
    #[error("error compiling C files (linking failed):\n{0}")]
    #[diagnostic(forward(0))]
    Link(#[from] LinkCModulesError),
}

/// Compiles a set of C files into a single dynamic library and places them under `{target_dir}/{target_file}`.
/// # Panics
/// Panics if no parent or no filename can be determined for the target path.
#[tracing::instrument(name = "Compiling C files", skip_all)]
pub(crate) async fn compile_c_files(
    files: &Vec<PathBuf>,
    target_module: &LuaModule,
    target_dir: &Path,
    lua: &LuaInstallation,
    external_dependencies: &HashMap<String, ExternalDependencyInfo>,
    config: &Config,
) -> Result<(), CompileCFilesError> {
    let target = target_dir.join(target_module.to_lib_path());
    let target_parent_dir = target.parent().unwrap_or_else(|| {
        unreachable!(
            "couldn't determine build target parent directory:\n{}",
            target.display()
        )
    });
    let target_file_name = target
        .file_name()
        .unwrap_or_default()
        .to_string_lossy()
        .to_string();

    fs::sync::create_dir_all(target_parent_dir)?;

    let host = Triple::host();

    // See https://github.com/rust-lang/cc-rs/issues/594#issuecomment-2110551057

    let mut build = cc::Build::new();
    let intermediate_dir = fs::tempfile::tempdir()?;
    let build = build
        .cargo_output(config.verbose())
        .cargo_debug(config.verbose())
        .cargo_metadata(config.verbose())
        .cargo_warnings(config.verbose())
        .warnings(config.verbose())
        .files(files)
        .host(&host.to_string())
        .target(&host.to_string())
        .includes(lua.includes())
        .includes(
            external_dependencies
                .values()
                .filter_map(|dep| dep.include_dir.as_ref()),
        )
        .opt_level(3)
        .out_dir(intermediate_dir);

    let compiler = build.try_get_compiler()?;
    // Suppress all warnings
    if compiler.is_like_msvc() {
        build.flag("-W0");
    } else {
        build.flag("-w");
    }
    for arg in lua.define_flags() {
        build.flag(&arg);
    }

    let objects = build
        .try_compile_objects(config)
        .map_err(|source| CompileCFilesError::CompileIntermediates { source })?;

    link_c_artifacts(
        build,
        lua,
        external_dependencies,
        &target_file_name,
        Vec::new(),
        Vec::new(),
        target_module,
        target_parent_dir,
        objects,
        config,
    )
    .await?;
    Ok(())
}

/// On MSVC, we need to create Lua definitions manually
#[tracing::instrument(level = "trace")]
fn mk_def_file(
    dir: &Path,
    output_file_name: &str,
    target_module: &LuaModule,
) -> Result<PathBuf, fs::FsError> {
    let mut def_file: PathBuf = dir.join(output_file_name);
    def_file.set_extension(".def");
    let exported_name = target_module.to_string().replace(".", "_");
    let exported_name = exported_name
        .split_once('-')
        .map(|(_, after_hyphen)| after_hyphen.to_string())
        .unwrap_or_else(|| exported_name.clone());
    let content = format!(
        r#"EXPORTS
luaopen_{exported_name}
"#,
    );
    fs::sync::write(&def_file, content)?;
    Ok(def_file)
}

// TODO: (#261): special cases for mingw/cygwin?

/// the extension for C shared libraries.
pub(crate) fn c_dylib_extension() -> &'static str {
    if cfg!(target_env = "msvc") {
        "dll"
    } else {
        "so"
    }
}

/// the extension for C static libraries.
pub(crate) fn c_lib_extension() -> &'static str {
    if cfg!(target_env = "msvc") {
        "lib"
    } else {
        "a"
    }
}

/// the extension for C objects.
pub(crate) fn c_obj_extension() -> &'static str {
    if cfg!(target_env = "msvc") {
        "obj"
    } else {
        "o"
    }
}

pub(crate) fn default_cflags() -> &'static str {
    if cfg!(target_env = "msvc") {
        "/NOLOGO /MD /O2"
    } else {
        "-O2"
    }
}

pub(crate) fn default_libflag() -> &'static str {
    if cfg!(target_os = "macos") {
        "-bundle -undefined dynamic_lookup -all_load"
    } else if cfg!(target_env = "msvc") {
        "/NOLOGO /DLL"
    } else {
        "-shared"
    }
}

#[derive(Error, Debug, Diagnostic)]
#[non_exhaustive]
pub enum CompileCModulesError {
    #[error(transparent)]
    #[diagnostic(transparent)]
    Fs(#[from] fs::FsError),
    #[error("failed to compile intermediates from C modules: {0}")]
    #[diagnostic(help(
        r#"check the compiler output above for missing headers or syntax errors.
ensure the C compiler has access to the required include paths."#
    ))]
    CompileIntermediates(cc::Error),
    #[error("error compiling C modules (compilation failed):\n{0}")]
    #[diagnostic(help(
        r#"check the compiler output above for details.
if no C compiler was found, run `lx debug toolchains` to verify your build tools."#
    ))]
    Compilation(#[from] cc::Error),
    #[error("error compiling C modules (linking failed):\n{0}")]
    #[diagnostic(forward(0))]
    Link(#[from] LinkCModulesError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    VariableSubstitution(#[from] VariableSubstitutionError),
}

#[derive(Error, Debug, Diagnostic)]
#[non_exhaustive]
pub enum LinkCModulesError {
    #[error("IO operation failed while linking C modules:\n{0}")]
    Io(#[from] io::Error),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Fs(#[from] fs::FsError),
    #[error(transparent)]
    CC(#[from] cc::Error),
    #[error("error compiling C modules (output validation failed): {0}")]
    #[diagnostic(forward(0))]
    OutputValidation(#[from] OutputValidationError),
    #[error("compiling C modules succeeded, but the expected library {0} was not created")]
    #[diagnostic(help(
        "check the rockspec or lux.toml `modules` and `lib_modules` tables for correct paths."
    ))]
    LibOutputNotCreated(String),
}

/// Compiles a set of C files (with extra metadata) to a given destination.
/// # Panics
/// Panics if no filename for the target path can be determined.
#[tracing::instrument(name = "Compiling C modules", skip_all)]
pub(crate) async fn compile_c_modules(
    data: &ModulePaths,
    source_dir: &Path,
    target_module: &LuaModule,
    target_dir: &Path,
    lua: &LuaInstallation,
    external_dependencies: &HashMap<String, ExternalDependencyInfo>,
    config: &Config,
) -> Result<(), CompileCModulesError> {
    let target = target_dir.join(target_module.to_lib_path());

    let target_parent_dir = target.parent().unwrap_or_else(|| {
        unreachable!(
            "couldn't determine build target parent directory:\n{}",
            target.display()
        )
    });

    fs::tokio::create_dir_all(target_parent_dir).await?;

    let host = Triple::host();

    let mut build = cc::Build::new();
    let source_files = data
        .sources
        .iter()
        .map(|dir| {
            variables::substitute(
                &[lua, external_dependencies, &Environment {}, config],
                &dir.to_slash_lossy(),
            )
        })
        .map(|dir| dir.map(|dir| source_dir.join(dir)))
        .try_collect::<_, Vec<_>, _>()?;
    let include_dirs = data
        .incdirs
        .iter()
        .map(|dir| {
            variables::substitute(
                &[lua, external_dependencies, &Environment {}, config],
                &dir.to_slash_lossy(),
            )
        })
        .map(|dir| dir.map(|dir| source_dir.join(dir)))
        .chain(
            external_dependencies
                .values()
                .filter_map(|dep| dep.include_dir.clone())
                .map(Result::Ok),
        )
        .try_collect::<_, Vec<_>, _>()?
        .into_iter()
        .unique() // Some rocks specify the include dirs via variable substitution.
        .collect_vec();

    let intermediate_dir = fs::tempfile::tempdir()?;
    let build = build
        .cargo_output(config.verbose())
        .cargo_debug(config.verbose())
        .cargo_metadata(config.verbose())
        .cargo_warnings(config.verbose())
        .warnings(config.verbose())
        .files(source_files)
        .host(&host.to_string())
        .target(&host.to_string())
        .includes(&include_dirs)
        .includes(lua.includes())
        .includes(
            external_dependencies
                .values()
                .filter_map(|dep| dep.include_dir.as_ref()),
        )
        .opt_level(3)
        .out_dir(intermediate_dir);

    let compiler = build.try_get_compiler()?;
    let is_msvc = compiler.is_like_msvc();
    // Suppress all warnings
    if is_msvc {
        build.flag("-W0");
    } else {
        build.flag("-w");
    }
    for arg in lua.define_flags() {
        build.flag(&arg);
    }

    // `cc::Build` has no `defines()` function, so we manually feed in the
    // definitions in a verbose loop
    for (name, value) in &data.defines {
        build.define(name, value.as_deref());
    }

    let target_file_name = target
        .file_name()
        .unwrap_or_default()
        .to_string_lossy()
        .to_string();
    // See https://github.com/rust-lang/cc-rs/issues/594#issuecomment-2110551057
    let objects = build
        .try_compile_objects(config)
        .map_err(CompileCModulesError::CompileIntermediates)?;

    let libdir_args = data
        .libdirs
        .iter()
        .map(|dir| {
            variables::substitute(
                &[lua, external_dependencies, &Environment {}, config],
                &dir.to_slash_lossy(),
            )
        })
        .map(|libdir| {
            libdir.map(|libdir| {
                if is_msvc {
                    format!("/LIBPATH:{}", source_dir.join(libdir).display())
                } else {
                    format!("-L{}", source_dir.join(libdir).display())
                }
            })
        })
        .try_collect::<_, Vec<_>, _>()?;

    let library_args = data
        .libraries
        .iter()
        .map(|dir| {
            variables::substitute(
                &[lua, external_dependencies, &Environment {}, config],
                &dir.to_slash_lossy(),
            )
        })
        .map(|library| {
            library.map(|library| {
                if is_msvc {
                    format!("{}.lib", library)
                } else {
                    format!("-l{}", library)
                }
            })
        })
        .try_collect::<_, Vec<_>, _>()?;

    link_c_artifacts(
        build,
        lua,
        external_dependencies,
        &target_file_name,
        libdir_args,
        library_args,
        target_module,
        target_parent_dir,
        objects,
        config,
    )
    .await?;
    Ok(())
}

#[allow(clippy::too_many_arguments)]
async fn link_c_artifacts(
    build: &mut cc::Build,
    lua: &LuaInstallation,
    external_dependencies: &HashMap<String, ExternalDependencyInfo>,
    target_file_name: &str,
    libdir_args: Vec<String>,
    library_args: Vec<String>,
    target_module: &LuaModule,
    target_parent_dir: &Path,
    objects: Vec<PathBuf>,
    config: &Config,
) -> Result<(), LinkCModulesError> {
    let output_path = target_parent_dir.join(target_file_name);
    let compiler = build.try_get_compiler()?;
    // Linking from within a temp directory makes sure the linker doesn't create any build artifacts in
    // the current working directory.
    // See https://github.com/lumen-oss/lux/issues/1106
    let temp_work_dir = fs::tempfile::tempdir()?;
    let is_msvc = compiler.is_like_msvc();
    let cmd = build.try_get_compiler()?.to_command();
    let mut cmd: tokio::process::Command = cmd.into();
    cmd.current_dir(temp_work_dir.path());
    add_variable_if_set(config, "LIBFLAG", &mut cmd);
    let output = if is_msvc {
        let def_file = mk_def_file(temp_work_dir.path(), target_file_name, target_module)?;
        cmd.arg("/NOIMPLIB").arg("/NOEXP").args(&objects).arg("/LD");
        add_variable_if_set(config, "LDFLAGS", &mut cmd);
        cmd.arg("/link")
            .arg(format!("/DEF:{}", def_file.display()))
            .arg(format!("/OUT:{}", output_path.display()))
            .args(lua_lib_link_args(lua, &compiler))
            .args(
                external_dependencies
                    .values()
                    .flat_map(|dep| dep.lib_link_args(&compiler)),
            )
            .args(libdir_args)
            .args(library_args)
            .output()
            .await?
    } else {
        add_variable_if_set(config, "LDFLAGS", &mut cmd);
        cmd.args(vec!["-o".into(), output_path.to_string_lossy().to_string()])
            .args(lua_lib_link_args(lua, &build.try_get_compiler()?))
            .args(
                external_dependencies
                    .values()
                    .flat_map(|dep| dep.lib_link_args(&compiler)),
            )
            .args(&objects)
            .args(libdir_args)
            .args(library_args)
            .output()
            .await?
    };
    trace_command_output(&output);
    validate_output(&output)?;

    if output_path.exists() {
        Ok(())
    } else {
        Err(LinkCModulesError::LibOutputNotCreated(
            output_path.to_slash_lossy().to_string(),
        ))
    }
}

/// NOTE: In luarocks, these are behind a link_lua_explicity config option
fn lua_lib_link_args(lua: &LuaInstallation, compiler: &cc::Tool) -> Vec<String> {
    if cfg!(target_os = "macos") {
        // On macos, linking Lua can lead to duplicate symbol errors
        Vec::new()
    } else {
        lua.lib_link_args(compiler)
    }
}

/// Lookup a variable in the `Config` and if set and non-empty,
/// split it into multiple arguments and add them to the `Command`.
fn add_variable_if_set(config: &Config, name: &str, cmd: &mut Command) {
    let variables = config.variables();
    if let Some(var_str) = variables.get(name) {
        if !var_str.is_empty() {
            let vars = var_str.split_whitespace().collect_vec();
            cmd.args(vars);
        }
    }
}

#[derive(Debug, Error, Diagnostic)]
pub enum InstallBinaryError {
    #[error(transparent)]
    #[diagnostic(transparent)]
    Fs(#[from] fs::FsError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Paths(#[from] PathsError),
    #[error("error wrapping binary")]
    #[diagnostic(forward(0))]
    Wrap(#[from] WrapBinaryError),
}

#[derive(Debug, Error, Diagnostic)]
pub enum WrapBinaryError {
    #[error(transparent)]
    #[diagnostic(transparent)]
    Fs(#[from] fs::FsError),
    #[error(transparent)]
    Utf8(#[from] FromUtf8Error),
    #[error("no Lua executable found")]
    NoLuaBinary {
        #[help]
        help: String,
    },
}

/// Returns the file path of the installed binary
pub(crate) async fn install_binary(
    source: &Path,
    target: &str,
    tree: &impl InstallTree,
    lua: &LuaInstallation,
    deploy: &DeploySpec,
    config: &Config,
) -> Result<PathBuf, InstallBinaryError> where
{
    let span = span!(
        tracing::Level::TRACE,
        "Installing binary...",
        source = source.to_slash_lossy().to_string(),
        target,
    );
    let _enter = span.enter();
    fs::tokio::create_dir_all(&tree.bin()).await?;
    let paths = Paths::new(tree)?;
    let script = if config.wrap_bin_scripts()
        && deploy.wrap_bin_scripts
        && is_compatible_lua_script(source, lua, &paths, config).await
    {
        install_wrapped_binary(source, target, tree, lua, config).await?
    } else {
        let target = tree.bin().join(target);
        fs::tokio::copy(source, &target).await?;
        target
    };

    #[cfg(unix)]
    set_executable_permissions(&script).await?;

    Ok(script)
}

/// Logs the output's stdout and stderr in verbose mode
pub(crate) fn trace_command_output(output: &Output) {
    if !output.stderr.is_empty() {
        for line in String::from_utf8_lossy(&output.stderr).lines() {
            tracing::debug!("{}", line);
        }
    }
    if !output.stdout.is_empty() {
        for line in String::from_utf8_lossy(&output.stdout).lines() {
            tracing::debug!("{}", line);
        }
    }
}

#[tracing::instrument("Installing wrapped binary", level = "trace", skip(tree))]
async fn install_wrapped_binary(
    source: &Path,
    target: &str,
    tree: &impl InstallTree,
    lua: &LuaInstallation,
    config: &Config,
) -> Result<PathBuf, WrapBinaryError> {
    let unwrapped_bin_dir = tree.unwrapped_bin();
    fs::tokio::create_dir_all(&unwrapped_bin_dir).await?;
    let unwrapped_bin = unwrapped_bin_dir.join(target);
    fs::tokio::copy(source, &unwrapped_bin).await?;

    #[cfg(target_family = "unix")]
    let target = tree.bin().join(target);
    #[cfg(target_family = "windows")]
    let target = tree.bin().join(format!("{}.bat", target));

    let lua_bin = lua.lua_binary_or_config_override(config).ok_or({
        let bin_name = config
            .variables()
            .get("LUA")
            .cloned()
            .or(lua
                .bin()
                .as_ref()
                .map(|bin| bin.to_slash_lossy().to_string()))
            .unwrap_or("lua".to_string());
        WrapBinaryError::NoLuaBinary {
            help: format!(
                "make sure {bin_name} (version {}) is on the PATH or has been installed by Lux",
                lua.version
            ),
        }
    })?;

    #[cfg(target_family = "unix")]
    let content = format!(
        r#"#!/bin/sh

exec "{0}" "{1}" "$@"
"#,
        lua_bin,
        unwrapped_bin.display(),
    );
    #[cfg(target_family = "windows")]
    let content = format!(
        r#"@echo off
setlocal

{0} "{1}" %*

exit /b %ERRORLEVEL%
"#,
        lua_bin,
        unwrapped_bin.display(),
    );

    fs::tokio::write(&target, content).await?;
    Ok(target)
}

#[cfg(unix)]
#[tracing::instrument(level = "trace")]
async fn set_executable_permissions(script: &Path) -> Result<(), fs::FsError> {
    let mut perms = fs::tokio::metadata(&script).await?.permissions();
    perms.set_mode(0o744);
    fs::tokio::set_permissions(&script, perms).await?;
    Ok(())
}

/// Tries to load the file with Lua. If the file can be loaded,
/// we treat it as a valid Lua script.
///
/// NOTE: Lua may successfully load very short bash scripts (like `echo "Hello"`).
/// But that's unlikely to be the case in practise.
/// If a script is mistaken for a Lua script, package authors can disable
/// wrapping with the `deploy.wrap_bin_scripts` rockspec config.
#[tracing::instrument(level = "trace")]
async fn is_compatible_lua_script(
    file: &Path,
    lua: &LuaInstallation,
    paths: &Paths,
    config: &Config,
) -> bool {
    let lua_path = paths.package_path_prepended().joined();
    let lua_cpath = paths.package_cpath_prepended().joined();
    let path = paths.path_prepended().joined();
    if let Some(lua_bin) = &lua.lua_binary_or_config_override(config) {
        let lua_bin_path = PathBuf::from(lua_bin);
        if lua_bin_path.is_file() || which(lua_bin).is_ok() {
            Command::new(lua_bin)
                .arg("-e")
                .arg(format!(
                    "if loadfile('{}') then os.exit(0) else os.exit(1) end",
                    // On Windows, Lua escapes path separators, so we ensure forward slashes
                    file.to_slash_lossy()
                ))
                .stderr(Stdio::null())
                .stdout(Stdio::null())
                .env("LUA_PATH", lua_path)
                .env("LUA_CPATH", lua_cpath)
                .env("PATH", path)
                .status()
                .await
                .is_ok_and(|status| status.success())
        } else {
            is_compatible_lua_script_fallback(file, lua, config).await
        }
    } else {
        false
    }
}

/// Fallback function for `is_compatible_lua_script`.
/// Uses the bundled Lua interpreter. This may be less reliable,
/// as it cannot load scripts for an incompatible Lua version.
/// But it works for test dependencies like nlua.
#[tracing::instrument(level = "trace")]
async fn is_compatible_lua_script_fallback(
    file: &Path,
    lua_installation: &LuaInstallation,
    config: &Config,
) -> bool {
    if let Ok(file_content) = fs::tokio::read_to_string(&file).await {
        let file_content_without_comments = file_content
            .lines()
            .filter(|line| !line.trim_start().starts_with("#"))
            .collect_vec()
            .join("\n");
        lua_installation
            .lua_binary_or_config_override(config)
            .is_some_and(|_| {
                // Try to compile the content without executing it
                ottavino::Lua::core()
                    .try_enter(|ctx| {
                        ottavino::Closure::load(
                            ctx,
                            None,
                            file_content_without_comments.as_bytes(),
                        )?;
                        Ok(())
                    })
                    .is_ok()
            })
    } else {
        false
    }
}

pub(crate) fn substitute_variables(
    input: &str,
    output_paths: &RockLayout,
    lua: &LuaInstallation,
    external_dependencies: &HashMap<String, ExternalDependencyInfo>,
    config: &Config,
) -> Result<String, VariableSubstitutionError> {
    variables::substitute(
        &[
            output_paths,
            lua,
            external_dependencies,
            &Environment {},
            config,
        ],
        input,
    )
}

pub(crate) fn format_path(path: &Path) -> String {
    let path_str = path.to_slash_lossy();
    if cfg!(windows) {
        path_str.to_string()
    } else {
        try_quote(&path_str)
            .map(|str| str.to_string())
            .unwrap_or(format!("'{path_str}'"))
    }
}

#[cfg(test)]
mod tests {
    use tokio::process::Command;

    use crate::{
        config::ConfigBuilder, lua_installation::detect_installed_lua_version,
        lua_version::LuaVersion, tree::Tree,
    };

    use super::*;

    #[tokio::test]
    async fn test_is_compatible_lua_script() {
        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
        let config = ConfigBuilder::new()
            .unwrap()
            .lua_version(lua_version)
            .build()
            .unwrap();
        let lua_version = config.lua_version().unwrap();
        let lua = LuaInstallation::new(lua_version, &config).await.unwrap();
        let valid_script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("resources/test/sample_lua_bin_script_valid");
        let temp_dir = assert_fs::TempDir::new().unwrap().to_path_buf();
        let tree = Tree::new(temp_dir, lua_version.clone(), &config).unwrap();
        let paths = Paths::new(&tree).unwrap();
        assert!(is_compatible_lua_script(&valid_script, &lua, &paths, &config).await);
        let invalid_script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("resources/test/sample_lua_bin_script_invalid");
        assert!(!is_compatible_lua_script(&invalid_script, &lua, &paths, &config).await);
    }

    #[tokio::test]
    async fn test_install_wrapped_binary() {
        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
        let temp = assert_fs::TempDir::new().unwrap();
        let config = ConfigBuilder::new()
            .unwrap()
            .lua_version(lua_version)
            .user_tree(Some(temp.to_path_buf()))
            .build()
            .unwrap();
        let lua_version = config.lua_version().unwrap();
        let lua = LuaInstallation::new(lua_version, &config).await.unwrap();
        let tree = config.user_tree(lua_version.clone()).unwrap();
        let valid_script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("resources/test/sample_lua_bin_script_valid");
        let script_name = "test_script";
        let script_path = install_wrapped_binary(&valid_script, script_name, &tree, &lua, &config)
            .await
            .unwrap();

        #[cfg(unix)]
        set_executable_permissions(&script_path).await.unwrap();

        assert!(Command::new(script_path.to_string_lossy().to_string())
            .status()
            .await
            .is_ok_and(|status| status.success()));
    }
}
