use crate::build::backend::{BuildBackend, BuildInfo, RunBuildArgs};
use crate::fs;
use crate::lockfile::{LockfileError, OptState, RemotePackageSourceUrl};
use crate::lua_installation::LuaInstallationError;
use crate::lua_rockspec::LuaVersionError;
use crate::operations::{RemotePackageSourceMetadata, UnpackError};
use crate::rockspec::{LuaVersionCompatibility, Rockspec};
use crate::tree::{self, EntryType, InstallTree, TreeError};
use bytes::Bytes;
use path_slash::PathBufExt;
use std::collections::HashMap;
use std::fs::DirEntry;
use std::io::Cursor;
use std::path::PathBuf;
use std::{io, path::Path};
use tracing::{span, Instrument};

use crate::{
    config::Config,
    hash::HasIntegrity,
    lockfile::{LocalPackage, LocalPackageHashes, LockConstraint, PinnedState},
    lua_installation::LuaInstallation,
    lua_rockspec::BuildBackendSpec,
    operations::{self, FetchSrcError},
    package::PackageSpec,
    remote_package_source::RemotePackageSource,
    tree::RockLayout,
};
use bon::Builder;
use builtin::BuiltinBuildError;
use cmake::CMakeError;
use command::CommandError;
use external_dependency::{ExternalDependencyError, ExternalDependencyInfo};

use itertools::Itertools;
use luarocks::LuarocksBuildError;
use make::MakeError;

use miette::Diagnostic;
use patch::{Patch, PatchError};
use rust_mlua::RustError;
use source::SourceBuildError;
use ssri::Integrity;
use thiserror::Error;
use treesitter_parser::TreesitterBuildError;
use utils::{recursive_copy_dir, CompileCFilesError, InstallBinaryError};

mod builtin;
mod cmake;
mod command;
mod luarocks;
mod make;
mod patch;
mod rust_mlua;
mod source;
mod treesitter_parser;

pub(crate) mod backend;
pub(crate) mod utils;

pub mod external_dependency;

/// A rocks package builder, providing fine-grained control
/// over how a package should be built.
#[derive(Builder)]
#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
pub struct Build<'a, R: Rockspec + HasIntegrity, T: InstallTree> {
    rockspec: &'a R,
    tree: &'a T,
    entry_type: tree::EntryType,
    config: &'a Config,
    lua: &'a LuaInstallation,

    #[builder(default)]
    pin: PinnedState,
    #[builder(default)]
    opt: OptState,
    #[builder(default)]
    constraint: LockConstraint,
    #[builder(default)]
    behaviour: BuildBehaviour,

    #[builder(setters(vis = "pub(crate)"))]
    source_spec: Option<RemotePackageSourceSpec>,

    // TODO(vhyrro): Remove this and enforce that this is provided at a type level.
    #[builder(setters(vis = "pub(crate)"))]
    source: Option<RemotePackageSource>,
}

#[derive(Debug)]
pub(crate) enum RemotePackageSourceSpec {
    RockSpec(Option<RemotePackageSourceUrl>),
    SrcRock(SrcRockSource),
}

/// A packed .src.rock archive.
#[derive(Debug)]
pub(crate) struct SrcRockSource {
    pub bytes: Bytes,
    pub source_url: RemotePackageSourceUrl,
}

// Overwrite the `build()` function to use our own instead.
impl<R: Rockspec + HasIntegrity, T: InstallTree + Sync, State> BuildBuilder<'_, R, T, State>
where
    State: build_builder::State + build_builder::IsComplete,
{
    pub async fn build(self) -> Result<LocalPackage, BuildError> {
        let build = self._build();
        let span = span!(
            tracing::Level::INFO,
            "Building",
            package = build.rockspec.package().to_string(),
            version = build.rockspec.version().to_string(),
        );
        do_build(build).instrument(span).await
    }
}

#[derive(Error, Debug, Diagnostic)]
#[non_exhaustive]
pub enum BuildError {
    #[error("builtin build failed: {0}")]
    #[diagnostic(forward(0))]
    Builtin(#[from] BuiltinBuildError),
    #[error("cmake build failed: {0}")]
    #[diagnostic(forward(0))]
    CMake(#[from] CMakeError),
    #[error("make build failed: {0}")]
    #[diagnostic(forward(0))]
    Make(#[from] MakeError),
    #[error("command build failed: {0}")]
    #[diagnostic(forward(0))]
    Command(#[from] CommandError),
    #[error("rust-mlua build failed: {0}")]
    #[diagnostic(forward(0))]
    Rust(#[from] RustError),
    #[error("treesitter-parser build failed: {0}")]
    #[diagnostic(forward(0))]
    TreesitterBuild(#[from] TreesitterBuildError),
    #[error("luarocks build failed: {0}")]
    #[diagnostic(forward(0))]
    LuarocksBuild(#[from] LuarocksBuildError),
    #[error("building from rock source failed: {0}")]
    #[diagnostic(forward(0))]
    SourceBuild(#[from] SourceBuildError),
    #[error("IO operation failed: {0}")]
    Io(#[from] io::Error),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Fs(#[from] fs::FsError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Lockfile(#[from] LockfileError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Tree(#[from] TreeError),

    #[error(transparent)]
    #[diagnostic(transparent)]
    ExternalDependencyError(#[from] ExternalDependencyError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    PatchError(#[from] PatchError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    CompileCFiles(#[from] CompileCFilesError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    LuaVersion(#[from] LuaVersionError),
    #[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("failed to unpack src.rock:\n{0}")]
    #[diagnostic(forward(0))]
    UnpackSrcRock(UnpackError),
    #[error("failed to fetch rock source:\n{0}")]
    #[diagnostic(forward(0))]
    FetchSrcError(#[from] FetchSrcError),
    #[error("failed to install binary '{file_name}'")]
    InstallBinary {
        file_name: String,
        #[diagnostic_source]
        source: InstallBinaryError,
    },
    #[error(transparent)]
    #[diagnostic(transparent)]
    LuaInstallation(#[from] LuaInstallationError),
}

#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Default)]
pub enum BuildBehaviour {
    /// Don't force a rebuild if the package is already installed
    #[default]
    NoForce,
    /// Force a rebuild if the package is already installed
    Force,
}

#[tracing::instrument(level = "trace", skip_all)]
async fn run_build<R: Rockspec + HasIntegrity, T: InstallTree + Sync>(
    rockspec: &R,
    args: RunBuildArgs<'_, T>,
) -> Result<BuildInfo, BuildError> {
    Ok(
        match rockspec.build().current_platform().build_backend.to_owned() {
            Some(BuildBackendSpec::Builtin(build_spec)) => build_spec.run(args).await?,
            Some(BuildBackendSpec::Make(make_spec)) => make_spec.run(args).await?,
            Some(BuildBackendSpec::CMake(cmake_spec)) => cmake_spec.run(args).await?,
            Some(BuildBackendSpec::Command(command_spec)) => command_spec.run(args).await?,
            Some(BuildBackendSpec::RustMlua(rust_mlua_spec)) => rust_mlua_spec.run(args).await?,
            Some(BuildBackendSpec::TreesitterParser(treesitter_parser_spec)) => {
                treesitter_parser_spec.run(args).await?
            }
            Some(BuildBackendSpec::LuaRock(_)) => luarocks::build(rockspec, args).await?,
            Some(BuildBackendSpec::Source) => source::build(args).await?,
            None => BuildInfo::default(),
        },
    )
}

#[allow(clippy::too_many_arguments)]
#[tracing::instrument(level = "trace", skip(rockspec, tree, config))]
async fn install<R: Rockspec + HasIntegrity, T: InstallTree>(
    rockspec: &R,
    tree: &T,
    output_paths: &RockLayout,
    lua: &LuaInstallation,
    build_dir: &Path,
    entry_type: &EntryType,
    config: &Config,
) -> Result<(), BuildError> {
    let install_spec = &rockspec.build().current_platform().install;
    {
        let span = span!(tracing::Level::INFO, "Copying Lua modules");
        let _enter = span.enter();
        for (target, source) in &install_spec.lua {
            let _enter = span.enter();
            let absolute_source = build_dir.join(source);
            utils::copy_lua_to_module_path(&absolute_source, target, &output_paths.src)?;
        }
    }
    {
        let span = span!(tracing::Level::INFO, "Compiling C libraries");
        let _enter = span.enter();
        for (target, source) in &install_spec.lib {
            let absolute_source = build_dir.join(source);
            let resolved_target = output_paths.lib.join(target);
            fs::tokio::copy(absolute_source, resolved_target).await?;
        }
    }
    if entry_type.is_entrypoint() {
        let span = span!(tracing::Level::INFO, "Installing binaries");
        let _enter = span.enter();
        let deploy_spec = rockspec.deploy().current_platform();
        for (target, source) in &install_spec.bin {
            utils::install_binary(
                &build_dir.join(source),
                target,
                tree,
                lua,
                deploy_spec,
                config,
            )
            .await
            .map_err(|err| BuildError::InstallBinary {
                file_name: target.clone(),
                source: err,
            })?;
        }
    }
    if !install_spec.conf.is_empty() {
        let span = span!(tracing::Level::INFO, "Copying configuration files");
        let _enter = span.enter();
        for (target, source) in &install_spec.conf {
            let span = span!(
                tracing::Level::TRACE,
                "Copying configuration file",
                source = source.to_slash_lossy().to_string(),
                target,
            );
            let _enter = span.enter();
            let absolute_source = build_dir.join(source);
            let target = output_paths.conf.join(target);
            if let Some(parent_dir) = target.parent() {
                fs::tokio::create_dir_all(parent_dir).await?;
            }
            fs::tokio::copy(absolute_source, target).await?;
        }
    }
    Ok(())
}

#[tracing::instrument(level = "trace", skip_all)]
async fn do_build<R, T>(build: Build<'_, R, T>) -> Result<LocalPackage, BuildError>
where
    R: Rockspec + HasIntegrity,
    T: InstallTree + Sync,
{
    let rockspec = build.rockspec;
    let lua = build.lua;

    rockspec.validate_lua_version(&lua.version)?;

    let tree = build.tree;

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

    let source_metadata = match build.source_spec {
        Some(RemotePackageSourceSpec::SrcRock(SrcRockSource { bytes, source_url })) => {
            let hash = bytes.hash().await?;
            let cursor = Cursor::new(bytes);
            operations::unpack_src_rock(cursor, temp_dir.path().to_path_buf())
                .await
                .map_err(BuildError::UnpackSrcRock)?;
            RemotePackageSourceMetadata { hash, source_url }
        }
        Some(RemotePackageSourceSpec::RockSpec(source_url)) => {
            operations::FetchSrc::new(temp_dir.path(), rockspec, build.config)
                .maybe_source_url(source_url)
                .fetch_internal()
                .await?
        }
        None => {
            operations::FetchSrc::new(temp_dir.path(), rockspec, build.config)
                .fetch_internal()
                .await?
        }
    };

    let hashes = LocalPackageHashes {
        rockspec: rockspec.hash().await?,
        source: source_metadata.hash.clone(),
    };

    let mut package = LocalPackage::from(
        &PackageSpec::new(rockspec.package().clone(), rockspec.version().clone()),
        build.constraint,
        rockspec.binaries(),
        build
            .source
            .map(Result::Ok)
            .unwrap_or_else(|| {
                rockspec
                    .to_lua_remote_rockspec_string()
                    .map(RemotePackageSource::RockspecContent)
            })
            .unwrap_or(RemotePackageSource::Local),
        Some(source_metadata.source_url.clone()),
        hashes,
    );
    package.spec.pinned = build.pin;
    package.spec.opt = build.opt;

    match tree.lockfile()?.get(&package.id()) {
        Some(package) if build.behaviour == BuildBehaviour::NoForce => Ok(package.clone()),
        _ => {
            let output_paths = match build.entry_type {
                tree::EntryType::Entrypoint => tree.entrypoint(&package)?,
                tree::EntryType::DependencyOnly => tree.dependency(&package)?,
            };

            let rock_source = rockspec.source().current_platform();
            let build_dir = match &rock_source.unpack_dir {
                Some(unpack_dir) => temp_dir.path().join(unpack_dir),
                None => {
                    // Some older/off-spec rockspecs don't specify a `source.dir`.
                    // After unpacking the archive, if
                    //
                    //   - there exist no Lua or C sources
                    //   - there exists a single subdirectory that is not a source
                    //     or etc directory
                    //
                    // we assume it's the `source.dir`.
                    // Unlike the LuaRocks implementation - which filters when fetching sources -
                    // we only infer `source.dir` if the directory name is not 'src', 'lua'
                    // or one of the `build.copy_directories`.
                    // This allows us to build local projects with only a `src` directory.
                    //
                    // LuaRocks implementation:
                    // https://github.com/luarocks/luarocks/blob/4188fdb235aca66530d274c782374cf6afba09b8/src/luarocks/fetch.tl?plain=1#L526
                    let has_lua_or_c_sources = fs::sync::read_dir(temp_dir.path())?
                        .filter_map(Result::ok)
                        .filter(|f| f.path().is_file())
                        .any(|f| {
                            f.path().extension().is_some_and(|ext| {
                                matches!(ext.to_string_lossy().to_string().as_str(), "lua" | "c")
                            })
                        });
                    if has_lua_or_c_sources {
                        temp_dir.path().into()
                    } else {
                        let dir_entries = fs::sync::read_dir(temp_dir.path())?
                            .filter_map(Result::ok)
                            .filter(|f| f.path().is_dir())
                            .collect_vec();
                        if dir_entries.len() == 1
                            && !is_source_or_etc_dir(
                                unsafe { dir_entries.first().unwrap_unchecked() },
                                rockspec,
                            )
                        {
                            unsafe {
                                temp_dir
                                    .path()
                                    .join(dir_entries.first().unwrap_unchecked().path())
                            }
                        } else {
                            temp_dir.path().into()
                        }
                    }
                }
            };

            Patch::new(&build_dir, &rockspec.build().current_platform().patches).apply()?;

            let external_dependencies = rockspec
                .external_dependencies()
                .current_platform()
                .iter()
                .map(|(name, dep)| {
                    ExternalDependencyInfo::probe(name, dep, build.config.external_deps())
                        .map(|info| (name.clone(), info))
                })
                .try_collect::<_, HashMap<_, _>, _>()?;

            let output = run_build(
                rockspec,
                RunBuildArgs::new()
                    .output_paths(&output_paths)
                    .no_install(false)
                    .lua(lua)
                    .external_dependencies(&external_dependencies)
                    .deploy(rockspec.deploy().current_platform())
                    .config(build.config)
                    .tree(tree)
                    .build_dir(&build_dir)
                    .build(),
            )
            .await?;

            package.spec.binaries.extend(output.binaries);

            install(
                rockspec,
                tree,
                &output_paths,
                lua,
                &build_dir,
                &build.entry_type,
                build.config,
            )
            .await?;

            for directory in rockspec
                .build()
                .current_platform()
                .copy_directories
                .iter()
                .filter(|dir| {
                    dir.file_name()
                        .is_some_and(|name| name != "doc" && name != "docs")
                })
            {
                recursive_copy_dir(
                    &build_dir.join(directory),
                    &output_paths.etc.join(directory),
                )
                .await?;
            }

            recursive_copy_doc_dir(&output_paths, &build_dir).await?;

            if let Ok(rockspec_str) = rockspec.to_lua_remote_rockspec_string() {
                fs::sync::write(output_paths.rockspec_path(), rockspec_str)?;
            }

            Ok(package)
        }
    }
}

fn is_source_or_etc_dir<R>(dir: &DirEntry, rockspec: &R) -> bool
where
    R: Rockspec + HasIntegrity,
{
    let copy_dirs = &rockspec.build().current_platform().copy_directories;
    let dir_name = dir.file_name().to_string_lossy().to_string();
    matches!(dir_name.as_str(), "lua" | "src")
        || copy_dirs
            .iter()
            .any(|copy_dir_name| copy_dir_name == &PathBuf::from(&dir_name))
}

#[tracing::instrument(level = "trace")]
async fn recursive_copy_doc_dir(
    output_paths: &RockLayout,
    build_dir: &Path,
) -> Result<(), BuildError> {
    let mut doc_dir = build_dir.join("doc");
    if !doc_dir.exists() {
        doc_dir = build_dir.join("docs");
    }
    recursive_copy_dir(&doc_dir, &output_paths.doc).await?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use predicates::prelude::*;
    use std::path::PathBuf;

    use assert_fs::{
        assert::PathAssert,
        prelude::{PathChild, PathCopy},
    };

    use crate::{
        config::ConfigBuilder,
        lua_installation::{detect_installed_lua_version, LuaInstallation},
        lua_version::LuaVersion,
        project::Project,
        tree::RockLayout,
    };

    #[tokio::test]
    async fn test_builtin_build() {
        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("resources/test/sample-projects/no-build-spec/");
        let tree_dir = assert_fs::TempDir::new().unwrap();
        let config = ConfigBuilder::new()
            .unwrap()
            .lua_version(lua_version)
            .user_tree(Some(tree_dir.to_path_buf()))
            .build()
            .unwrap();
        let build_dir = assert_fs::TempDir::new().unwrap();
        build_dir.copy_from(&project_root, &["**"]).unwrap();
        let tree = config
            .user_tree(config.lua_version().cloned().unwrap())
            .unwrap();
        let dest_dir = assert_fs::TempDir::new().unwrap();
        let rock_layout = RockLayout {
            rock_path: dest_dir.to_path_buf(),
            etc: dest_dir.join("etc"),
            lib: dest_dir.join("lib"),
            src: dest_dir.join("src"),
            bin: tree.bin(),
            conf: dest_dir.join("conf"),
            doc: dest_dir.join("doc"),
        };
        let lua_version = config.lua_version().unwrap_or(&LuaVersion::Lua51);
        let lua = LuaInstallation::new(lua_version, &config).await.unwrap();
        let project = Project::from_exact(&project_root).unwrap().unwrap();
        let rockspec = project.toml().into_remote(None).unwrap();
        run_build(
            &rockspec,
            RunBuildArgs::new()
                .output_paths(&rock_layout)
                .no_install(false)
                .lua(&lua)
                .external_dependencies(&HashMap::default())
                .deploy(rockspec.deploy().current_platform())
                .config(&config)
                .tree(&tree)
                .build_dir(&build_dir)
                .build(),
        )
        .await
        .unwrap();
        let foo_dir = dest_dir.child("src").child("foo");
        foo_dir.assert(predicate::path::is_dir());
        let foo_init = foo_dir.child("init.lua");
        foo_init.assert(predicate::path::is_file());
        foo_init.assert(predicate::str::contains("return true"));
        let foo_bar_dir = foo_dir.child("bar");
        foo_bar_dir.assert(predicate::path::is_dir());
        let foo_bar_init = foo_bar_dir.child("init.lua");
        foo_bar_init.assert(predicate::path::is_file());
        foo_bar_init.assert(predicate::str::contains("return true"));
        let foo_bar_baz = foo_bar_dir.child("baz.lua");
        foo_bar_baz.assert(predicate::path::is_file());
        foo_bar_baz.assert(predicate::str::contains("return true"));
        let bin_file = tree_dir
            .child(lua_version.to_string())
            .child("bin")
            .child("hello");
        bin_file.assert(predicate::path::is_file());
        bin_file.assert(predicate::str::contains("#!/usr/bin/env bash"));
        bin_file.assert(predicate::str::contains("echo \"Hello\""));
    }
}
