use itertools::Itertools;

use miette::Diagnostic;
use path_slash::PathExt;
use serde::{Deserialize, Deserializer};
use serde_enum_str::Serialize_enum_str;
use std::{convert::Infallible, path::PathBuf};
use thiserror::Error;

use crate::lua_version::LuaVersion;

use crate::{
    config::{Config, ConfigBuilder, ConfigError},
    lua_rockspec::per_platform_from_intermediate,
    package::PackageReq,
    project::{project_toml::LocalProjectTomlValidationError, Project},
    rockspec::Rockspec,
};

use super::{PartialOverride, PerPlatform, PlatformOverridable};

#[cfg(target_family = "unix")]
const NLUA_EXE: &str = "nlua";
#[cfg(target_family = "windows")]
const NLUA_EXE: &str = "nlua.bat";

#[derive(Error, Debug, Diagnostic)]
#[non_exhaustive]
pub enum TestSpecDecodeError {
    #[error("the 'command' test type must specify either a 'command' or 'script' field")]
    NoCommandOrScript,
    #[error("the 'command' test type cannot have both 'command' and 'script' fields")]
    CommandAndScript,
}

#[derive(Error, Debug, Diagnostic)]
#[non_exhaustive]
pub enum TestSpecError {
    #[error("could not auto-detect the test spec. Please add one to your lux.toml")]
    NoTestSpecDetected,
    #[error("project validation failed:\n{0}")]
    #[diagnostic(forward(0))]
    LocalProjectTomlValidation(#[from] LocalProjectTomlValidationError),
}

#[derive(Clone, Debug, PartialEq, Default)]
pub enum TestSpec {
    #[default]
    AutoDetect,
    Busted(BustedTestSpec),
    BustedNlua(BustedTestSpec),
    Command(CommandTestSpec),
    Script(LuaScriptTestSpec),
}

#[derive(Clone, Debug, PartialEq)]
pub(crate) enum ValidatedTestSpec {
    Busted {
        spec: BustedTestSpec,
        dependencies: Vec<PackageReq>,
    },
    BustedNlua {
        spec: BustedTestSpec,
        dependencies: Vec<PackageReq>,
    },
    Command(CommandTestSpec),
    LuaScript(LuaScriptTestSpec),
}

impl TestSpec {
    pub(crate) fn test_dependencies(&self, project: &Project) -> Vec<PackageReq> {
        self.to_validated(project)
            .ok()
            .iter()
            .flat_map(|spec| spec.test_dependencies())
            .collect_vec()
    }

    pub(crate) fn to_validated(
        &self,
        project: &Project,
    ) -> Result<ValidatedTestSpec, TestSpecError> {
        let project_root = project.root();
        let toml = project.toml().into_local()?;
        let test_dependencies = toml.test_dependencies().current_platform();
        let busted_dependency = test_dependencies
            .iter()
            .find(|dep| dep.name().to_string() == "busted");
        let busted = busted_dependency
            .map(|dep| dep.package_req.clone())
            .unwrap_or_else(|| unsafe { PackageReq::new_unchecked("busted".into(), None) });
        let nlua_dependency = test_dependencies
            .iter()
            .find(|dep| dep.name().to_string() == "nlua");
        let nlua = nlua_dependency
            .map(|dep| dep.package_req.clone())
            .unwrap_or_else(|| unsafe { PackageReq::new_unchecked("nlua".into(), None) });
        let is_busted = project_root.join(".busted").is_file() || busted_dependency.is_some();
        match self {
            Self::AutoDetect if is_busted => {
                if nlua_dependency.is_some() {
                    Ok(ValidatedTestSpec::BustedNlua {
                        spec: BustedTestSpec::default(),
                        dependencies: vec![busted, nlua],
                    })
                } else {
                    Ok(ValidatedTestSpec::Busted {
                        spec: BustedTestSpec::default(),
                        dependencies: vec![busted],
                    })
                }
            }
            Self::Busted(spec) => Ok(ValidatedTestSpec::Busted {
                spec: spec.clone(),
                dependencies: vec![busted],
            }),
            Self::BustedNlua(spec) => Ok(ValidatedTestSpec::BustedNlua {
                spec: spec.clone(),
                dependencies: vec![busted, nlua],
            }),
            Self::Command(spec) => Ok(ValidatedTestSpec::Command(spec.clone())),
            Self::Script(spec) => Ok(ValidatedTestSpec::LuaScript(spec.clone())),
            Self::AutoDetect => Err(TestSpecError::NoTestSpecDetected),
        }
    }
}

impl ValidatedTestSpec {
    pub fn args(&self) -> Vec<String> {
        match self {
            Self::Busted {
                spec,
                dependencies: _,
            } => spec.flags.clone(),
            Self::BustedNlua {
                spec,
                dependencies: _,
            } => {
                let mut flags = spec.flags.clone();
                // If there's a .busted config file which has lua set to "nlua",
                // we tell busted to ignore it, because we set the correct
                // platform-dependent "nlua" executable in the wrapper script.
                flags.push("--ignore-lua".into());
                flags
            }
            Self::Command(spec) => spec.flags.clone(),
            Self::LuaScript(spec) => std::iter::once(spec.script.to_slash_lossy().to_string())
                .chain(spec.flags.clone())
                .collect_vec(),
        }
    }

    pub(crate) fn test_config(&self, config: &Config) -> Result<Config, ConfigError> {
        match self {
            Self::BustedNlua { .. } => {
                let config_builder: ConfigBuilder = config.clone().into();

                // XXX: On macos and msvc, Neovim and LuaJIT segfault when
                // requiring luafilesystem's `lfs` module
                // if it is built with Lua 5.1 headers.
                #[cfg(not(any(target_os = "macos", target_env = "msvc")))]
                let lua_version = LuaVersion::Lua51;

                #[cfg(any(target_os = "macos", target_env = "msvc"))]
                let lua_version = LuaVersion::LuaJIT;

                Ok(config_builder
                    .lua_version(Some(lua_version))
                    .variables(Some(
                        vec![("LUA".to_string(), NLUA_EXE.to_string())]
                            .into_iter()
                            .collect(),
                    ))
                    .build()?)
            }
            _ => Ok(config.clone()),
        }
    }

    fn test_dependencies(&self) -> Vec<PackageReq> {
        match self {
            Self::Busted {
                spec: _,
                dependencies,
            } => dependencies.clone(),
            Self::BustedNlua {
                spec: _,
                dependencies,
            } => dependencies.clone(),
            Self::Command(_) => Vec::new(),
            Self::LuaScript(_) => Vec::new(),
        }
    }
}

impl TryFrom<TestSpecInternal> for TestSpec {
    type Error = TestSpecDecodeError;

    fn try_from(internal: TestSpecInternal) -> Result<Self, Self::Error> {
        let test_spec = match internal.test_type {
            Some(TestType::Busted) => Ok(Self::Busted(BustedTestSpec {
                flags: internal.flags.unwrap_or_default(),
            })),
            Some(TestType::BustedNlua) => Ok(Self::BustedNlua(BustedTestSpec {
                flags: internal.flags.unwrap_or_default(),
            })),
            Some(TestType::Command) => match (internal.command, internal.lua_script) {
                (None, None) => Err(TestSpecDecodeError::NoCommandOrScript),
                (None, Some(script)) => Ok(Self::Script(LuaScriptTestSpec {
                    script,
                    flags: internal.flags.unwrap_or_default(),
                })),
                (Some(command), None) => Ok(Self::Command(CommandTestSpec {
                    command,
                    flags: internal.flags.unwrap_or_default(),
                })),
                (Some(_), Some(_)) => Err(TestSpecDecodeError::CommandAndScript),
            },
            None => Ok(Self::default()),
        }?;
        Ok(test_spec)
    }
}

impl<'de> Deserialize<'de> for PerPlatform<TestSpec> {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        per_platform_from_intermediate::<_, TestSpecInternal, _>(deserializer)
    }
}

impl<'de> Deserialize<'de> for TestSpec {
    fn deserialize<D>(deserializer: D) -> Result<TestSpec, D::Error>
    where
        D: Deserializer<'de>,
    {
        let internal = TestSpecInternal::deserialize(deserializer)?;
        let test_spec = TestSpec::try_from(internal).map_err(serde::de::Error::custom)?;
        Ok(test_spec)
    }
}

/// Specification for running a test suite with busted
#[derive(Clone, Debug, PartialEq, Default)]
pub struct BustedTestSpec {
    /// Additional CLI flags to pass to Busted when running
    pub(crate) flags: Vec<String>,
}

impl BustedTestSpec {
    pub fn flags(&self) -> &Vec<String> {
        &self.flags
    }
}

/// Specification for running a test suite with a command
#[derive(Clone, Debug, PartialEq)]
pub struct CommandTestSpec {
    /// The command to run
    pub(crate) command: String,
    /// Additional CLI flags to pass to Busted when running
    pub(crate) flags: Vec<String>,
}

impl CommandTestSpec {
    pub fn flags(&self) -> &Vec<String> {
        &self.flags
    }

    pub fn command(&self) -> &str {
        &self.command
    }
}

/// Specification for running a test suite with a Lua script
#[derive(Clone, Debug, PartialEq)]
pub struct LuaScriptTestSpec {
    /// The script to run
    pub(crate) script: PathBuf,
    /// Additional CLI flags to pass to the script when running
    pub(crate) flags: Vec<String>,
}

impl LuaScriptTestSpec {
    pub fn flags(&self) -> &Vec<String> {
        &self.flags
    }

    pub fn script(&self) -> &PathBuf {
        &self.script
    }
}

#[derive(Debug, Deserialize, Serialize_enum_str, PartialEq, Clone)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum TestType {
    Busted,
    BustedNlua,
    Command,
}

#[derive(Debug, PartialEq, Deserialize, Default, Clone)]
pub(crate) struct TestSpecInternal {
    #[serde(default, rename = "type")]
    pub(crate) test_type: Option<TestType>,
    #[serde(default)]
    pub(crate) flags: Option<Vec<String>>,
    #[serde(default)]
    pub(crate) command: Option<String>,
    #[serde(default, rename = "script", alias = "lua_script")]
    pub(crate) lua_script: Option<PathBuf>,
}

impl PartialOverride for TestSpecInternal {
    type Err = Infallible;

    fn apply_overrides(&self, override_spec: &Self) -> Result<Self, Self::Err> {
        Ok(TestSpecInternal {
            test_type: override_opt(&override_spec.test_type, &self.test_type),
            flags: match (override_spec.flags.clone(), self.flags.clone()) {
                (Some(override_vec), Some(base_vec)) => {
                    let merged: Vec<String> =
                        base_vec.into_iter().chain(override_vec).unique().collect();
                    Some(merged)
                }
                (None, base_vec @ Some(_)) => base_vec,
                (override_vec @ Some(_), None) => override_vec,
                _ => None,
            },
            command: match override_spec.lua_script.clone() {
                Some(_) => None,
                None => override_opt(&override_spec.command, &self.command),
            },
            lua_script: match override_spec.command.clone() {
                Some(_) => None,
                None => override_opt(&override_spec.lua_script, &self.lua_script),
            },
        })
    }
}

impl PlatformOverridable for TestSpecInternal {
    type Err = Infallible;

    fn on_nil<T>() -> Result<PerPlatform<T>, <Self as PlatformOverridable>::Err>
    where
        T: PlatformOverridable,
        T: Default,
    {
        Ok(PerPlatform::default())
    }
}

fn override_opt<T: Clone>(override_opt: &Option<T>, base: &Option<T>) -> Option<T> {
    match override_opt.clone() {
        override_val @ Some(_) => override_val,
        None => base.clone(),
    }
}

#[cfg(test)]
mod tests {

    use ottavino::{Closure, Executor, Fuel, Lua};
    use ottavino_util::serde::from_value;

    use crate::lua_rockspec::PlatformIdentifier;

    use super::*;

    fn exec_lua<T: serde::de::DeserializeOwned>(
        code: &str,
        key: &'static str,
    ) -> Result<T, ottavino::ExternError> {
        Lua::core().try_enter(|ctx| {
            let closure = Closure::load(ctx, None, code.as_bytes())?;
            let executor = Executor::start(ctx, closure.into(), ());
            executor.step(ctx, &mut Fuel::with(i32::MAX))?;
            from_value(ctx.globals().get_value(ctx, key)).map_err(ottavino::Error::from)
        })
    }

    #[tokio::test]
    pub async fn test_spec_from_lua() {
        let lua_content = "
        test = {\n
        }\n
        ";
        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
        assert!(matches!(test_spec.default, TestSpec::AutoDetect));
        let lua_content = "
        test = {\n
            type = 'busted',\n
        }\n
        ";
        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
        assert_eq!(
            test_spec.default,
            TestSpec::Busted(BustedTestSpec::default())
        );
        let lua_content = "
        test = {\n
            type = 'busted',\n
            flags = { 'foo', 'bar' },\n
        }\n
        ";
        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
        assert_eq!(
            test_spec.default,
            TestSpec::Busted(BustedTestSpec {
                flags: vec!["foo".into(), "bar".into()],
            })
        );
        let lua_content = "
        test = {\n
            type = 'command',\n
        }\n
        ";
        let result: Result<PerPlatform<TestSpec>, _> = exec_lua(lua_content, "test");
        let _err = result.unwrap_err();
        let lua_content = "
        test = {\n
            type = 'command',\n
            command = 'foo',\n
            script = 'bar',\n
        }\n
        ";
        let result: Result<PerPlatform<TestSpec>, _> = exec_lua(lua_content, "test");
        let _err = result.unwrap_err();
        let lua_content = "
        test = {\n
            type = 'command',\n
            command = 'baz',\n
            flags = { 'foo', 'bar' },\n
        }\n
        ";
        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
        assert_eq!(
            test_spec.default,
            TestSpec::Command(CommandTestSpec {
                command: "baz".into(),
                flags: vec!["foo".into(), "bar".into()],
            })
        );
        let lua_content = "
        test = {\n
            type = 'command',\n
            script = 'test.lua',\n
            flags = { 'foo', 'bar' },\n
        }\n
        ";
        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
        assert_eq!(
            test_spec.default,
            TestSpec::Script(LuaScriptTestSpec {
                script: PathBuf::from("test.lua"),
                flags: vec!["foo".into(), "bar".into()],
            })
        );
        let lua_content = "
        test = {\n
            type = 'command',\n
            command = 'baz',\n
            flags = { 'foo', 'bar' },\n
            platforms = {\n
                unix = { flags = { 'baz' }, },\n
                macosx = {\n
                    script = 'bat.lua',\n
                    flags = { 'bat' },\n
                },\n
                linux = { type = 'busted' },\n
            },\n
        }\n
        ";
        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
        assert_eq!(
            test_spec.default,
            TestSpec::Command(CommandTestSpec {
                command: "baz".into(),
                flags: vec!["foo".into(), "bar".into()],
            })
        );
        let unix = test_spec
            .per_platform
            .get(&PlatformIdentifier::Unix)
            .unwrap();
        assert_eq!(
            *unix,
            TestSpec::Command(CommandTestSpec {
                command: "baz".into(),
                flags: vec!["foo".into(), "bar".into(), "baz".into()],
            })
        );
        let macosx = test_spec
            .per_platform
            .get(&PlatformIdentifier::MacOSX)
            .unwrap();
        assert_eq!(
            *macosx,
            TestSpec::Script(LuaScriptTestSpec {
                script: "bat.lua".into(),
                flags: vec!["foo".into(), "bar".into(), "bat".into(), "baz".into()],
            })
        );
        let linux = test_spec
            .per_platform
            .get(&PlatformIdentifier::Linux)
            .unwrap();
        assert_eq!(
            *linux,
            TestSpec::Busted(BustedTestSpec {
                flags: vec!["foo".into(), "bar".into(), "baz".into()],
            })
        );
        let lua_content = "
        test = {\n
            type = 'busted-nlua',\n
        }";
        let test_spec: PerPlatform<TestSpec> = exec_lua(lua_content, "test").unwrap();
        assert_eq!(
            test_spec.default,
            TestSpec::BustedNlua(BustedTestSpec { flags: Vec::new() })
        );
    }
}
