use std::{
    env,
    io::{self, Cursor},
    path::Path,
    process::{ExitStatus, Stdio},
};

use crate::{
    build::utils::{self, CCExt},
    config::Config,
    fs,
    hash::HasIntegrity,
    lua_version::LuaVersion,
    operations::{self, UnpackError},
};
use bon::Builder;
use git2::{build::RepoBuilder, FetchOptions};
use miette::Diagnostic;
use path_slash::PathExt;
use ssri::Integrity;
use target_lexicon::Triple;
use thiserror::Error;
use tokio::process::Command;
use tracing::span;
use url::Url;

const LUA51_VERSION: &str = "5.1.5";
const LUA51_HASH: &str = "sha256-JkD8VqeV8p0o7xXhPDSkfiI5YLAkDoywqC2bBzhpUzM=";
const LUA52_VERSION: &str = "5.2.4";
const LUA52_HASH: &str = "sha256-ueLkqtZ4mztjoFbUQveznw7Pyjrg8fwK5OlhRAG2n0s=";
const LUA53_VERSION: &str = "5.3.6";
const LUA53_HASH: &str = "sha256-/F/Wm7hzYyPwJmcrG3I12mE9cXfnJViJOgvc0yBGbWA=";
const LUA54_VERSION: &str = "5.4.8";
const LUA54_HASH: &str = "sha256-TxjdrhVOeT5G7qtyfFnvHAwMK3ROe5QhlxDXb1MGKa4=";
const LUA55_VERSION: &str = "5.5.0";
const LUA55_HASH: &str = "sha256-V8zDK7vQBcq3W8xSREBSU1r2kXiduiuQFtXFBkDWiz0=";
// XXX: there's no tag with lua 5.2 compatibility, so we have to use the v2.1 branch for now
// this is unstable and might break the build.
const LUAJIT_MM_VERSION: &str = "2.1";

#[derive(Builder)]
#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
pub struct BuildLua<'a> {
    lua_version: &'a LuaVersion,
    install_dir: &'a Path,
    config: &'a Config,
}

#[derive(Debug, Error, Diagnostic)]
pub enum BuildLuaError {
    #[error(transparent)]
    Request(#[from] reqwest::Error),
    #[error(transparent)]
    Io(#[from] io::Error),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Fs(#[from] fs::FsError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Unpack(#[from] UnpackError),
    #[error(transparent)]
    Git(#[from] git2::Error),
    #[error(transparent)]
    CC(#[from] cc::Error),
    #[error("failed to find cl.exe")]
    ClNotFound,
    #[error("failed to find LINK.exe")]
    LinkNotFound,
    #[error(
        r#"source integrity mismatch.
- source: {src}
- expected: {expected}
- got: {actual}"#
    )]
    #[diagnostic(help(
        r#"the source may have been modified or a tag may have been moved.
check the source, then rerun the command with `lx --no-lock` to update the hash."#
    ))]
    SourceIntegrityMismatch {
        src: String,
        expected: Integrity,
        actual: Integrity,
    },
    #[error("{name} failed.\n\n{status}\n\nstdout:\n{stdout}\n\nstderr:\n{stderr}")]
    CommandFailure {
        name: String,
        status: ExitStatus,
        stdout: String,
        stderr: String,
    },
}

impl<State: build_lua_builder::State + build_lua_builder::IsComplete> BuildLuaBuilder<'_, State> {
    pub async fn build(self) -> Result<(), BuildLuaError> {
        let args = self._build();
        let lua_version = args.lua_version;
        match lua_version {
            LuaVersion::Lua51
            | LuaVersion::Lua52
            | LuaVersion::Lua53
            | LuaVersion::Lua54
            | LuaVersion::Lua55 => do_build_lua(args).await,
            LuaVersion::LuaJIT | LuaVersion::LuaJIT52 => do_build_luajit(args).await,
        }
    }
}

async fn do_build_luajit(args: BuildLua<'_>) -> Result<(), BuildLuaError> {
    let span = span!(
        tracing::Level::INFO,
        "Building LuaJIT",
        version = LUAJIT_MM_VERSION,
    );
    let _enter = span.enter();

    let build_dir = fs::tempfile::tempdir()?;
    // XXX luajit.org responds with an invalid content-type, so we'll use the github mirror for now.
    // let luajit_url = "https://luajit.org/git/luajit.git";
    let luajit_url = "https://github.com/LuaJIT/LuaJIT.git";

    {
        let span = span!(
            tracing::Level::INFO,
            "Cloning LuaJIT sources",
            url = luajit_url,
        );
        let _enter = span.enter();

        // We create a new scope because we have to drop fetch_options before the await
        let mut fetch_options = FetchOptions::new();
        fetch_options.update_fetchhead(false);
        let mut repo_builder = RepoBuilder::new();
        repo_builder.fetch_options(fetch_options);
        let repo = repo_builder.clone(luajit_url, build_dir.path())?;
        let (object, _) = repo.revparse_ext(&format!("v{LUAJIT_MM_VERSION}"))?;
        repo.checkout_tree(&object, None)?;
    }
    if cfg!(target_env = "msvc") {
        do_build_luajit_msvc(args, build_dir.path()).await
    } else {
        do_build_luajit_unix(args, build_dir.path()).await
    }
}

#[tracing::instrument(name = "Compiling LuaJIT", skip_all)]
async fn do_build_luajit_unix(args: BuildLua<'_>, build_dir: &Path) -> Result<(), BuildLuaError> {
    let lua_version = args.lua_version;
    let config = args.config;
    let install_dir = args.install_dir;

    let host = Triple::host();

    let mut cc = cc::Build::new();
    cc.cargo_output(false)
        .cargo_metadata(false)
        .cargo_warnings(false)
        .warnings(config.verbose())
        .opt_level(3)
        .host(&host.to_string())
        .target(&host.to_string());
    let compiler = cc.try_get_compiler()?;
    let compiler_path = compiler.path().to_slash_lossy().to_string();
    let mut make_cmd = Command::new(config.make_cmd());
    make_cmd.current_dir(build_dir.join("src"));
    make_cmd.arg("-e");
    make_cmd.stdout(Stdio::piped());
    make_cmd.stderr(Stdio::piped());
    let target = host.to_string();
    match target.as_str() {
        "x86_64-apple-darwin" if env::var_os("MACOSX_DEPLOYMENT_TARGET").is_none() => {
            make_cmd.env("MACOSX_DEPLOYMENT_TARGET", "10.11");
        }
        "aarch64-apple-darwin" if env::var_os("MACOSX_DEPLOYMENT_TARGET").is_none() => {
            make_cmd.env("MACOSX_DEPLOYMENT_TARGET", "11.0");
        }
        _ if target.contains("linux") => {
            make_cmd.env("TARGET_SYS", "Linux");
        }
        _ => {}
    }
    let compiler_path = which::which(&compiler_path)
        .map_err(|err| io::Error::other(format!("cannot find {}:\n{}", compiler_path, err)))?;
    let compiler_path = compiler_path.to_slash_lossy().to_string();
    let compiler_args = compiler.cflags_env();
    let compiler_args = compiler_args.to_string_lossy();
    if env::var_os("STATIC_CC").is_none() {
        make_cmd.env("STATIC_CC", format!("{compiler_path} {compiler_args}"));
    }
    if env::var_os("TARGET_LD").is_none() {
        make_cmd.env("TARGET_LD", format!("{compiler_path} {compiler_args}"));
    }
    let mut xcflags = vec!["-fPIC"];
    if lua_version == &LuaVersion::LuaJIT52 {
        xcflags.push("-DLUAJIT_ENABLE_LUA52COMPAT");
    }
    if cfg!(debug_assertions) {
        xcflags.push("-DLUA_USE_ASSERT");
        xcflags.push("-DLUA_USE_APICHECK");
    }
    make_cmd.env("BUILDMODE", "static");
    make_cmd.env("XCFLAGS", xcflags.join(" "));

    match make_cmd.output().await {
        Ok(output) if output.status.success() => utils::trace_command_output(&output),
        Ok(output) => {
            return Err(BuildLuaError::CommandFailure {
                name: "build".into(),
                status: output.status,
                stdout: String::from_utf8_lossy(&output.stdout).into(),
                stderr: String::from_utf8_lossy(&output.stderr).into(),
            });
        }
        Err(err) => {
            return Err(BuildLuaError::Io(io::Error::other(format!(
                "Failed to run `{} build`:\n{}",
                config.make_cmd(),
                err
            ))));
        }
    };

    match Command::new(config.make_cmd())
        .current_dir(build_dir)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .arg("install")
        .arg(format!(r#"PREFIX="{}""#, install_dir.display()))
        .output()
        .await
    {
        Ok(output) if output.status.success() => utils::trace_command_output(&output),
        Ok(output) => {
            return Err(BuildLuaError::CommandFailure {
                name: "install".into(),
                status: output.status,
                stdout: String::from_utf8_lossy(&output.stdout).into(),
                stderr: String::from_utf8_lossy(&output.stderr).into(),
            });
        }
        Err(err) => {
            return Err(BuildLuaError::Io(io::Error::other(format!(
                "Failed to run `{} install`:\n{}",
                config.make_cmd(),
                err
            ))));
        }
    };
    move_luajit_includes(install_dir).await?;
    Ok(())
}

/// luajit installs the includes to a subdirectory.
/// For consistency, we want them in the `include` directory
async fn move_luajit_includes(install_dir: &Path) -> io::Result<()> {
    let include_dir = install_dir.join("include");
    let include_subdir = include_dir.join(format!("luajit-{LUAJIT_MM_VERSION}"));
    if !include_subdir.is_dir() {
        return Ok(());
    }
    let mut dir = fs::tokio::read_dir(&include_subdir)
        .await
        .map_err(|err| io::Error::other(err.to_string()))?;
    while let Some(entry) = dir.next_entry().await? {
        let file_name = entry.file_name();
        let src_path = entry.path();
        let dest_path = include_dir.join(&file_name);
        fs::tokio::copy(&src_path, &dest_path)
            .await
            .map_err(|err| io::Error::other(err.to_string()))?;
    }
    fs::tokio::remove_dir_all(&include_subdir)
        .await
        .map_err(|err| io::Error::other(err.to_string()))?;
    Ok(())
}

#[tracing::instrument(name = "Compiling LuaJIT", skip_all)]
async fn do_build_luajit_msvc(args: BuildLua<'_>, build_dir: &Path) -> Result<(), BuildLuaError> {
    let lua_version = args.lua_version;
    let install_dir = args.install_dir;
    let lib_dir = install_dir.join("lib");
    fs::tokio::create_dir_all(&lib_dir)
        .await
        .map_err(|err| io::Error::other(err.to_string()))?;
    let include_dir = install_dir.join("include");
    fs::tokio::create_dir_all(&include_dir)
        .await
        .map_err(|err| io::Error::other(err.to_string()))?;
    let bin_dir = install_dir.join("bin");
    fs::tokio::create_dir_all(&bin_dir)
        .await
        .map_err(|err| io::Error::other(err.to_string()))?;

    let src_dir = build_dir.join("src");
    let mut msvcbuild = Command::new(src_dir.join("msvcbuild.bat"));
    msvcbuild.current_dir(&src_dir);
    if lua_version == &LuaVersion::LuaJIT52 {
        msvcbuild.arg("lua52compat");
    }
    msvcbuild.arg("static");
    let host = Triple::host();
    let target = host.to_string();
    let cl = cc::windows_registry::find_tool(&target, "cl.exe").ok_or(BuildLuaError::ClNotFound)?;
    for (k, v) in cl.env() {
        msvcbuild.env(k, v);
    }
    fs::tokio::create_dir_all(&install_dir)
        .await
        .map_err(|err| io::Error::other(err.to_string()))?;
    match msvcbuild.output().await {
        Ok(output) if output.status.success() => utils::trace_command_output(&output),
        Ok(output) => {
            return Err(BuildLuaError::CommandFailure {
                name: "build".into(),
                status: output.status,
                stdout: String::from_utf8_lossy(&output.stdout).into(),
                stderr: String::from_utf8_lossy(&output.stderr).into(),
            });
        }
        Err(err) => {
            return Err(BuildLuaError::Io(io::Error::other(format!(
                "Failed to run msvcbuild.bat:\n{}",
                err
            ))))
        }
    };

    copy_includes(&src_dir, &include_dir).await?;
    fs::tokio::copy(src_dir.join("lua51.lib"), lib_dir.join("luajit.lib"))
        .await
        .map_err(|err| io::Error::other(err.to_string()))?;
    fs::tokio::copy(src_dir.join("luajit.exe"), bin_dir.join("luajit.exe"))
        .await
        .map_err(|err| io::Error::other(err.to_string()))?;
    Ok(())
}

async fn do_build_lua(args: BuildLua<'_>) -> Result<(), BuildLuaError> {
    let lua_version = args.lua_version;
    let span = span!(
        tracing::Level::INFO,
        "Building Lua",
        version = lua_version.to_string(),
    );
    let _enter = span.enter();

    let build_dir = fs::tempfile::tempdir()?;

    let (source_integrity, pkg_version): (Integrity, &str) = unsafe {
        match lua_version {
            LuaVersion::Lua51 => (LUA51_HASH.parse().unwrap_unchecked(), LUA51_VERSION),
            LuaVersion::Lua52 => (LUA52_HASH.parse().unwrap_unchecked(), LUA52_VERSION),
            LuaVersion::Lua53 => (LUA53_HASH.parse().unwrap_unchecked(), LUA53_VERSION),
            LuaVersion::Lua54 => (LUA54_HASH.parse().unwrap_unchecked(), LUA54_VERSION),
            LuaVersion::Lua55 => (LUA55_HASH.parse().unwrap_unchecked(), LUA55_VERSION),
            LuaVersion::LuaJIT | LuaVersion::LuaJIT52 => unreachable!(),
        }
    };

    let file_name = format!("lua-{pkg_version}.tar.gz");

    let source_url: Url = unsafe {
        format!("https://www.lua.org/ftp/{file_name}")
            .parse()
            .unwrap_unchecked()
    };

    let response = {
        let span = span!(
            tracing::Level::INFO,
            "Downloading Lua",
            url = source_url.to_string(),
        );
        let _enter = span.enter();

        crate::reqwest::https_client(args.config)?
            .get(source_url.clone())
            .send()
            .await?
            .error_for_status()?
            .bytes()
            .await?
    };

    let hash = response.hash().await?;

    if hash.matches(&source_integrity).is_none() {
        return Err(BuildLuaError::SourceIntegrityMismatch {
            src: source_url.to_string(),
            expected: source_integrity,
            actual: hash,
        });
    }

    let cursor = Cursor::new(response);
    let mime_type = infer::get(cursor.get_ref()).map(|file_type| file_type.mime_type());
    operations::unpack::unpack(mime_type, cursor, true, file_name, build_dir.path()).await?;

    if cfg!(target_env = "msvc") {
        do_build_lua_msvc(args, build_dir.path(), lua_version, pkg_version).await
    } else {
        do_build_lua_unix(args, build_dir.path(), lua_version, pkg_version).await
    }
}

#[tracing::instrument(name = "Compiling Lua", skip_all)]
async fn do_build_lua_unix(
    args: BuildLua<'_>,
    build_dir: &Path,
    lua_version: &LuaVersion,
    _pkg_version: &str,
) -> Result<(), BuildLuaError> {
    tracing::debug!(message = "Compiling Lua (Unix)...");
    let config = args.config;
    let install_dir = args.install_dir;

    let build_target = if cfg!(target_os = "linux") {
        // only lua 5.4 has a specific `linux-readline` target
        if matches!(&lua_version, LuaVersion::Lua54) {
            "linux-readline"
        } else {
            "linux"
        }
    } else if cfg!(target_os = "macos") {
        "macosx"
    } else if cfg!(target_os = "freebsd") {
        "freebsd"
    } else {
        "generic"
    };
    match Command::new(config.make_cmd())
        .current_dir(build_dir)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .arg(build_target)
        .output()
        .await
    {
        // The lua build may fail for some platform specific reason.
        // Typically, the `readline` headers cannot be found. In these
        // cases, try the build again with the `generic` platform target.
        Ok(output) if !output.status.success() && build_target != "generic" => {
            let fallback_output = Command::new(config.make_cmd())
                .current_dir(build_dir)
                .stdout(Stdio::piped())
                .stderr(Stdio::piped())
                .arg("generic")
                .output()
                .await;
            guard_success(fallback_output, config, "build (generic)")?;
        }
        output => guard_success(output, config, &format!("build ({build_target})"))?,
    };

    match Command::new(config.make_cmd())
        .current_dir(build_dir)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .arg("install")
        .arg(format!(r#"INSTALL_TOP="{}""#, install_dir.display()))
        .output()
        .await
    {
        Ok(output) if output.status.success() => utils::trace_command_output(&output),
        Ok(output) => {
            return Err(BuildLuaError::CommandFailure {
                name: "install".into(),
                status: output.status,
                stdout: String::from_utf8_lossy(&output.stdout).into(),
                stderr: String::from_utf8_lossy(&output.stderr).into(),
            });
        }
        Err(err) => {
            return Err(BuildLuaError::Io(io::Error::other(format!(
                "Failed to run `{} install`:\n{}",
                config.make_cmd(),
                err
            ))))
        }
    };

    Ok(())
}

#[tracing::instrument(name = "Compiling Lua", skip_all)]
async fn do_build_lua_msvc(
    args: BuildLua<'_>,
    build_dir: &Path,
    lua_version: &LuaVersion,
    _pkg_version: &str,
) -> Result<(), BuildLuaError> {
    tracing::debug!(message = "Compiling Lua (MSVC)...");
    let config = args.config;
    let install_dir = args.install_dir;

    let lib_dir = install_dir.join("lib");
    fs::tokio::create_dir_all(&lib_dir)
        .await
        .map_err(|err| io::Error::other(err.to_string()))?;
    let include_dir = install_dir.join("include");
    fs::tokio::create_dir_all(&include_dir)
        .await
        .map_err(|err| io::Error::other(err.to_string()))?;
    let bin_dir = install_dir.join("bin");
    fs::tokio::create_dir_all(&bin_dir)
        .await
        .map_err(|err| io::Error::other(err.to_string()))?;

    let src_dir = build_dir.join("src");

    let lua_bin_name = "lua";
    let lua_c_bin_name = "luac";

    let dll_name = match lua_version {
        LuaVersion::Lua51 => "lua51",
        LuaVersion::Lua52 => "lua52",
        LuaVersion::Lua53 => "lua53",
        LuaVersion::Lua54 => "lua54",
        LuaVersion::Lua55 => "lua55",
        LuaVersion::LuaJIT | LuaVersion::LuaJIT52 => unreachable!(),
    };

    let lib_name = match lua_version {
        LuaVersion::Lua51 => "lua5.1",
        LuaVersion::Lua52 => "lua5.2",
        LuaVersion::Lua53 => "lua5.3",
        LuaVersion::Lua54 => "lua5.4",
        LuaVersion::Lua55 => "lua5.5",
        LuaVersion::LuaJIT | LuaVersion::LuaJIT52 => unreachable!(),
    };

    let host = Triple::host();
    let mut cc = cc::Build::new();
    cc.cargo_output(false)
        .cargo_metadata(false)
        .cargo_warnings(false)
        .warnings(config.verbose())
        .opt_level(3)
        .host(&host.to_string())
        .target(&host.to_string());

    cc.define("LUA_USE_WINDOWS", None);
    cc.define("LUA_BUILD_AS_DLL", None);

    let mut lib_c_files = Vec::new();
    let mut read_dir = fs::tokio::read_dir(&src_dir)
        .await
        .map_err(|err| io::Error::other(err.to_string()))?;
    while let Some(entry) = read_dir.next_entry().await? {
        let path = entry.path();
        if path.extension().is_some_and(|ext| ext == "c")
            && path
                .with_extension("")
                .file_name()
                .is_some_and(|name| name != "lua" && name != "luac")
        {
            lib_c_files.push(path);
        }
    }

    let lib_objects = cc
        .include(&src_dir)
        .files(lib_c_files)
        .out_dir(&lib_dir)
        .try_compile_objects(config)?;

    let bin_objects = cc
        .include(&src_dir)
        .file(src_dir.join(format!("{lua_bin_name}.c")))
        .file(src_dir.join(format!("{lua_c_bin_name}.c")))
        .out_dir(&src_dir)
        .try_compile_objects(config)?;

    let lua_bin_objects = bin_objects.iter().filter(|file| {
        file.file_stem().is_some_and(|fname| {
            fname
                .to_string_lossy()
                .ends_with(&format!("-{lua_bin_name}"))
        })
    });

    let lua_c_bin_objects = bin_objects.iter().filter(|file| {
        file.file_stem().is_some_and(|fname| {
            fname
                .to_string_lossy()
                .ends_with(&format!("-{lua_c_bin_name}"))
        })
    });

    let target = host.to_string();
    let link =
        cc::windows_registry::find_tool(&target, "link.exe").ok_or(BuildLuaError::LinkNotFound)?;

    let dll_path = bin_dir.join(format!("{dll_name}.dll"));
    let lua_bin_path = bin_dir.join(format!("{lua_bin_name}.exe"));
    let lua_c_bin_path = bin_dir.join(format!("{lua_c_bin_name}.exe"));

    let implib_path = lib_dir.join(format!("{lib_name}.lib"));

    // lua.dll
    guard_success(
        Command::new(link.path())
            .arg("/DLL")
            .arg(format!("/OUT:{}", dll_path.display()))
            .arg(format!("/IMPLIB:{}", implib_path.display()))
            .args(&lib_objects)
            .output()
            .await,
        config,
        &format!("link {dll_name}.dll"),
    )?;

    // lua.exe
    guard_success(
        Command::new(link.path())
            .arg(format!("/OUT:{}", lua_bin_path.display()))
            .arg(&implib_path)
            .args(lua_bin_objects)
            .output()
            .await,
        config,
        &format!("link {}", lua_bin_path.display()),
    )?;

    // luac.exe
    guard_success(
        Command::new(link.path())
            .arg(format!("/OUT:{}", lua_c_bin_path.display()))
            .args(&lib_objects)
            .args(lua_c_bin_objects)
            .output()
            .await,
        config,
        &format!("link {}", lua_c_bin_path.display()),
    )?;

    copy_includes(&src_dir, &include_dir).await?;

    Ok(())
}

fn guard_success(
    output: io::Result<std::process::Output>,
    config: &Config,
    cmd_name: &str,
) -> Result<(), BuildLuaError> {
    match output {
        Ok(output) if output.status.success() => {
            utils::trace_command_output(&output);
            Ok(())
        }
        Ok(output) => Err(BuildLuaError::CommandFailure {
            name: cmd_name.to_string(),
            status: output.status,
            stdout: String::from_utf8_lossy(&output.stdout).into(),
            stderr: String::from_utf8_lossy(&output.stderr).into(),
        }),
        Err(err) => Err(BuildLuaError::Io(io::Error::other(format!(
            "Failed to run `{} build`:\n{}",
            config.make_cmd(),
            err
        )))),
    }
}

async fn copy_includes(src_dir: &Path, include_dir: &Path) -> Result<(), io::Error> {
    for f in &[
        "lauxlib.h",
        "lua.h",
        "luaconf.h",
        "luajit.h",
        "lualib.h",
        "lua.hpp",
    ] {
        let src_file = src_dir.join(f);
        if src_file.is_file() {
            fs::tokio::copy(&src_file, include_dir.join(f))
                .await
                .map_err(|err| io::Error::other(err.to_string()))?;
        }
    }
    Ok(())
}
