use std::{
    io,
    ops::Deref,
    path::{Path, PathBuf},
};

use crate::{
    config::{Config, ConfigBuilder, ConfigError},
    fs,
    lockfile::{LockfileError, ReadOnly, WorkspaceLockfile},
    lua_rockspec::LuaVersionError,
    lua_version::LuaVersion,
    package::PackageName,
    project::{Project, ProjectError, TomlDeError, PROJECT_TOML},
    tree::{InstallTree, Tree, TreeError},
    workspace::workspace_toml::{WorkspaceMemberSpec, WorkspaceToml},
};
use glob::glob;
use itertools::Itertools;
use lets_find_up::{find_up_with, FindUpKind, FindUpOptions};
use miette::Diagnostic;
use nonempty::NonEmpty;
use path_slash::PathBufExt;
use thiserror::Error;

pub mod workspace_toml;

pub const WORKSPACE_TOML: &str = PROJECT_TOML;
pub(crate) const LUX_DIR_NAME: &str = ".lux";
const EMMYRC: &str = ".emmyrc.json";
const CONFIG_TOML: &str = "config.toml";

/// A newtype for the workspace root directory.
/// This is used to ensure that the workspace root is a valid project directory.
#[derive(Clone, Debug)]
#[cfg_attr(test, derive(Default))]
pub struct WorkspaceRoot(PathBuf);

impl AsRef<Path> for WorkspaceRoot {
    fn as_ref(&self) -> &Path {
        self.0.as_ref()
    }
}

impl Deref for WorkspaceRoot {
    type Target = PathBuf;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

#[derive(Debug, Error, Diagnostic)]
pub enum WorkspaceError {
    #[error("cannot read the current working directory")]
    #[diagnostic(help("make sure Lux has permissions to read the current working directory"))]
    GetCwd(io::Error),
    #[error("error reading workspace TOML at '{toml_path}'")]
    #[diagnostic(help("make sure the file exists and contains valid UTF-8"))]
    ReadLuxTOML {
        toml_path: String,
        source: io::Error,
    },
    #[error("error deserializing {WORKSPACE_TOML}")]
    #[diagnostic(transparent)]
    TOML { source: TomlDeError },
    #[error("no workspace found at '{0}'")]
    #[diagnostic(help("make sure the directory contains a '{WORKSPACE_TOML}'"))]
    WorkspaceNotFound(PathBuf),
    #[error("glob error: '{0}'")]
    #[diagnostic(help("check the glob pattern in your {WORKSPACE_TOML}'s '[workspace.members]'"))]
    Glob(String),
    #[error(transparent)]
    #[diagnostic(forward(0))]
    Project(#[from] ProjectError),
    #[error("no project or workspace found at '{0}'")]
    #[diagnostic(help("make sure the directory contains a '{WORKSPACE_TOML}'"))]
    NoWorkspaceOrProject(PathBuf),
    #[error("empty workspace at '{0}'")]
    #[diagnostic(
        help(
            "a Lux workspace must have at least one project, declared using '[workspace.members]'"
        ),
        url("https://lux.lumen-labs.org/reference/lux-toml")
    )]
    EmptyWorkspace(PathBuf),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Lockfile(#[from] LockfileError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    Fs(#[from] fs::FsError),
    #[error("package must be specified in a multi-project workspace")]
    #[diagnostic(help(
        r#"this workspace contains multiple projects.
specify the package with '--package=[PACKAGE_NAME]'
    "#
    ))]
    NoPackageSpecified,
    #[error("package '{0}' not found in workspace '{1}'")]
    #[diagnostic(help(
        "make sure it is declared in your {WORKSPACE_TOML}'s '[workspace.members]'"
    ))]
    PackageNotFound(PackageName, WorkspaceRoot),
}

#[derive(Error, Debug, Diagnostic)]
#[non_exhaustive]
pub enum WorkspaceTreeError {
    #[error(transparent)]
    #[diagnostic(transparent)]
    Tree(#[from] TreeError),
    #[error(transparent)]
    #[diagnostic(transparent)]
    LuaVersionError(#[from] LuaVersionError),
}

/// A workspace, which can contain one or many Lux projects
#[derive(Clone, Debug)]
pub struct Workspace {
    root: WorkspaceRoot,
    members: NonEmpty<Project>,
}

#[derive(Error, Debug, Diagnostic)]
#[non_exhaustive]
#[error("error loading '{}'", .path.display())]
pub struct WorkspaceConfigError {
    path: PathBuf,
    #[diagnostic(source)]
    source: Box<ConfigError>,
}

// TODO: move lockfile from project to workspace

impl Workspace {
    pub fn current() -> Result<Option<Self>, WorkspaceError> {
        let cwd = std::env::current_dir().map_err(WorkspaceError::GetCwd)?;
        Self::from(&cwd)
    }

    pub fn current_or_err() -> Result<Self, WorkspaceError> {
        let cwd = std::env::current_dir().map_err(WorkspaceError::GetCwd)?;
        Self::current()?.ok_or(WorkspaceError::NoWorkspaceOrProject(cwd))
    }

    /// The path where the root `lux.toml` resides.
    pub fn root(&self) -> &WorkspaceRoot {
        &self.root
    }

    /// The members of this workspace.
    pub fn members(&self) -> &NonEmpty<Project> {
        &self.members
    }

    /// Mutable reference to the members of this workspace.
    pub fn members_mut(&mut self) -> &mut NonEmpty<Project> {
        &mut self.members
    }

    /// Get a workspace member, defaulting to the first one if none is specified.
    /// Fails if a package name is specified, but not found.
    pub fn single_member_or_select(
        &self,
        name: &Option<PackageName>,
    ) -> Result<&Project, WorkspaceError> {
        match name {
            Some(name) => self
                .members()
                .iter()
                .find(|project| &project.toml().package == name)
                .ok_or_else(|| WorkspaceError::PackageNotFound(name.clone(), self.root.clone())),
            None => Ok(self.members().first()),
        }
    }

    /// Get a mutable workspace member, defaulting to the first one if none is specified.
    /// Fails if a package name is specified, but not found.
    pub fn single_member_or_select_mut(
        &mut self,
        package: &Option<PackageName>,
    ) -> Result<&mut Project, WorkspaceError> {
        match package.as_ref() {
            Some(package) => self.select_member_mut(package),
            None => self.single_member_mut(),
        }
    }

    /// Get the single member of this workspace, failing if it has multiple members.
    pub fn single_member(&self) -> Result<&Project, WorkspaceError> {
        if self.members().len() == 1 {
            Ok(self.members().first())
        } else {
            Err(WorkspaceError::NoPackageSpecified)
        }
    }

    /// Get the single mutable member of this workspace, failing if it has multiple members.
    pub fn single_member_mut(&mut self) -> Result<&mut Project, WorkspaceError> {
        if self.members().len() == 1 {
            Ok(self.members_mut().first_mut())
        } else {
            Err(WorkspaceError::NoPackageSpecified)
        }
    }

    /// Select a member of this workspace, failing if it is not found.
    pub fn select_member(&self, package: &PackageName) -> Result<&Project, WorkspaceError> {
        let workspace_root = self.root.clone();
        self.members()
            .iter()
            .find(|project| &project.toml().package == package)
            .ok_or_else(|| WorkspaceError::PackageNotFound(package.clone(), workspace_root))
    }

    /// Select a mutable member of this workspace, failing if it is not found.
    pub fn select_member_mut(
        &mut self,
        package: &PackageName,
    ) -> Result<&mut Project, WorkspaceError> {
        let workspace_root = self.root.clone();
        self.members_mut()
            .iter_mut()
            .find(|project| &project.toml().package == package)
            .ok_or_else(|| WorkspaceError::PackageNotFound(package.clone(), workspace_root))
    }

    /// Get the `lux.lock` lockfile path.
    pub fn lockfile_path(&self) -> PathBuf {
        self.root.join("lux.lock")
    }

    /// Get the `lux.lock` lockfile in the project root.
    pub fn lockfile(&self) -> Result<WorkspaceLockfile<ReadOnly>, WorkspaceError> {
        Ok(WorkspaceLockfile::new(self.lockfile_path())?)
    }

    /// Get the `lux.lock` lockfile in the project root, if present.
    pub fn try_lockfile(&self) -> Result<Option<WorkspaceLockfile<ReadOnly>>, WorkspaceError> {
        let path = self.lockfile_path();
        if path.is_file() {
            Ok(Some(WorkspaceLockfile::load(path)?))
        } else {
            Ok(None)
        }
    }

    pub fn tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
        self.lua_version_tree(self.lua_version(config)?, config)
    }

    pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
        let mut lua_version = self.members().first().lua_version(config)?;
        // Ensure the lua version specified by the config matches all projects
        for project in self.members() {
            lua_version = project.lua_version(config)?;
        }
        Ok(lua_version)
    }

    pub(crate) fn lua_version_tree(
        &self,
        lua_version: LuaVersion,
        config: &Config,
    ) -> Result<Tree, WorkspaceTreeError> {
        let root_dir = config
            .workspace_tree()
            .map(|p| p.to_path_buf())
            .unwrap_or(self.workspace_dir());
        Ok(Tree::new(root_dir, lua_version, config)?)
    }

    /// This workspace's .lux directory.
    pub(crate) fn workspace_dir(&self) -> PathBuf {
        self.root.join(LUX_DIR_NAME)
    }

    pub fn test_tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
        Ok(self.tree(config)?.test_tree(config)?)
    }

    pub fn build_tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
        Ok(self.tree(config)?.build_tree(config)?)
    }

    /// The path to this workspace's local config file.
    pub fn config_file(&self) -> PathBuf {
        self.workspace_dir().join(CONFIG_TOML)
    }

    /// Load a [`ConfigBuilder`] from a config.toml that resides in [`Self::workspace_dir`],
    /// if present.
    pub fn config(&self) -> Result<Option<ConfigBuilder>, WorkspaceConfigError> {
        let config_file = self.config_file();
        if config_file.is_file() {
            Ok(Some(ConfigBuilder::from_file(&config_file).map_err(
                |err| WorkspaceConfigError {
                    path: config_file.to_path_buf(),
                    source: Box::new(err),
                },
            )?))
        } else {
            Ok(None)
        }
    }

    /// Get the `.luarc.json` or `.emmyrc.json` path.
    pub fn luarc_path(&self, config: &Config) -> PathBuf {
        let configured_name = config.luarc_file_name();
        let file_path = self.root.join(configured_name);

        if file_path.is_file() {
            file_path
        } else {
            let emmy_path = self.root.join(EMMYRC);
            if emmy_path.is_file() {
                emmy_path
            } else {
                file_path
            }
        }
    }

    #[tracing::instrument(level = "trace", skip_all)]
    pub fn from_exact(start: impl AsRef<Path>) -> Result<Option<Self>, WorkspaceError> {
        if !start.as_ref().exists() {
            return Ok(None);
        }
        if start.as_ref().join(WORKSPACE_TOML).exists() {
            let toml_path = start.as_ref().join(WORKSPACE_TOML);
            let toml_content = fs::sync::read_to_string(&toml_path)?;
            let root = start.as_ref();
            let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
            if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
                Ok(Some(Self::from_toml(&toml_content, root)?))
            } else {
                let project = Project::from_exact(root)?
                    .ok_or_else(|| WorkspaceError::NoWorkspaceOrProject(root.to_path_buf()))?;
                Ok(Some(Workspace {
                    root: WorkspaceRoot(root.to_path_buf()),
                    members: NonEmpty::new(project),
                }))
            }
        } else {
            Ok(None)
        }
    }

    #[tracing::instrument(level = "trace", skip(start))]
    pub fn from(start: impl AsRef<Path>) -> Result<Option<Self>, WorkspaceError> {
        if !start.as_ref().exists() {
            return Ok(None);
        }
        match find_up_with(
            WORKSPACE_TOML,
            FindUpOptions {
                cwd: start.as_ref(),
                kind: FindUpKind::File,
            },
        ) {
            Ok(Some(path)) => {
                if let Some(root) = path.parent() {
                    let toml_content = fs::sync::read_to_string(&path)?;
                    let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
                    if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
                        Ok(Some(Self::from_toml(&toml_content, root)?))
                    } else {
                        if let Some(parent) = root.parent() {
                            match Self::from(parent)? {
                                Some(workspace)
                                    if workspace
                                        .members
                                        .iter()
                                        .any(|project| project.root().as_ref() == root) =>
                                {
                                    Ok(Some(workspace))
                                }
                                _ => {
                                    let project = Project::from_exact(root)?.ok_or_else(|| {
                                        WorkspaceError::NoWorkspaceOrProject(root.to_path_buf())
                                    })?;
                                    Ok(Some(Workspace {
                                        root: WorkspaceRoot(root.to_path_buf()),
                                        members: NonEmpty::new(project),
                                    }))
                                }
                            }
                        } else {
                            Ok(None)
                        }
                    }
                } else {
                    Ok(None)
                }
            }
            // NOTE: If we hit a read error, it could be because we haven't found a PROJECT_TOML
            // or WORKSPACE_TOML and have started searching too far upwards.
            // See for example https://github.com/lumen-oss/lux/issues/532
            _ => Ok(None),
        }
    }

    fn from_toml(toml_content: &str, root: &Path) -> Result<Self, WorkspaceError> {
        let toml = WorkspaceToml::new(WORKSPACE_TOML, toml_content)
            .map_err(|source| WorkspaceError::TOML { source })?;
        let mut members = Vec::new();
        for member in toml.workspace.members {
            match member {
                WorkspaceMemberSpec::RelativeProjectGlob(pattern) => {
                    let potential_paths = glob(root.join(pattern).to_slash_lossy().deref())
                        .ok() // This is fine because we fail to deserialize invalid globs
                        .into_iter()
                        .flat_map(|paths| {
                            paths.map(|path| {
                                path.map_err(|err| WorkspaceError::Glob(err.to_string()))
                            })
                        })
                        .try_collect::<_, Vec<_>, _>()?;
                    for project_path in potential_paths {
                        if let Some(project) = Project::from_exact(&project_path)? {
                            members.push(project)
                        }
                    }
                }
                WorkspaceMemberSpec::RelativeProjectPath(relative_project_path) => {
                    let project_path = root.join(relative_project_path);
                    match Project::from_exact(&project_path)? {
                        Some(project) => members.push(project),
                        None => return Err(WorkspaceError::WorkspaceNotFound(project_path)),
                    }
                }
            }
        }
        match NonEmpty::from_vec(members) {
            Some(members) => Ok(Workspace {
                root: WorkspaceRoot(root.to_path_buf()),
                members,
            }),
            None => Err(WorkspaceError::EmptyWorkspace(root.to_path_buf())),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{config::ConfigBuilder, fs, lua_version::LuaVersion};
    use std::path::PathBuf;

    use assert_fs::prelude::*;

    #[tokio::test]
    async fn find_single_project_workspace() {
        let sample_project =
            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
        let project_root = assert_fs::TempDir::new().unwrap();
        project_root.copy_from(&sample_project, &["**"]).unwrap();
        let work_dir: PathBuf = project_root.join("src");
        let workspace = Workspace::from(&work_dir).unwrap().unwrap();
        assert_eq!(workspace.members.len(), 1);
        let project = workspace.members.first();
        assert_eq!(project.root().to_path_buf(), project_root.to_path_buf());
    }

    #[tokio::test]
    async fn find_nested_single_project_workspace() {
        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("resources/test/sample-projects/nested/");
        let project_root = assert_fs::TempDir::new().unwrap();
        project_root.copy_from(&sample_project, &["**"]).unwrap();
        let nested_project_root: PathBuf = project_root.join("nested");
        let workspace = Workspace::from(&nested_project_root).unwrap().unwrap();
        assert_eq!(workspace.members.len(), 1);
        let project = workspace.members.first();
        assert_eq!(
            project.root().to_path_buf(),
            nested_project_root.to_path_buf()
        );
    }

    #[tokio::test]
    async fn find_multi_project_workspace() {
        let sample_workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("resources/test/sample-projects/multi-project/");
        let workspace_root = assert_fs::TempDir::new().unwrap();
        workspace_root
            .copy_from(&sample_workspace, &["**"])
            .unwrap();
        let work_dir: PathBuf = workspace_root.join("projects");
        let workspace = Workspace::from(&work_dir).unwrap().unwrap();
        assert_eq!(workspace.members.len(), 2);
        let foo = workspace.select_member(&"foo".into()).unwrap();
        assert_eq!(
            foo.root().to_path_buf(),
            workspace_root.join("projects/foo").to_path_buf()
        );
        let bar = workspace.select_member(&"bar".into()).unwrap();
        assert_eq!(
            bar.root().to_path_buf(),
            workspace_root.join("projects/bar").to_path_buf()
        );
    }

    #[tokio::test]
    async fn find_multi_project_workspace_members_glob() {
        let sample_workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("resources/test/sample-projects/multi-project/");
        let workspace_root = assert_fs::TempDir::new().unwrap();
        workspace_root
            .copy_from(&sample_workspace, &["**"])
            .unwrap();
        let work_dir: PathBuf = workspace_root.join("projects");
        let workspace_toml_file = workspace_root.join(WORKSPACE_TOML);
        let workspace_toml_content = r#"
[workspace]
members = [ "glob:projects/*" ]
"#;
        fs::tokio::write(&workspace_toml_file, workspace_toml_content)
            .await
            .unwrap();

        let workspace = Workspace::from(&work_dir).unwrap().unwrap();
        assert_eq!(workspace.members.len(), 2);
        let foo = workspace.select_member(&"foo".into()).unwrap();
        assert_eq!(
            foo.root().to_path_buf(),
            workspace_root.join("projects/foo").to_path_buf()
        );
        let bar = workspace.select_member(&"bar".into()).unwrap();
        assert_eq!(
            bar.root().to_path_buf(),
            workspace_root.join("projects/bar").to_path_buf()
        );
    }

    #[tokio::test]
    async fn test_no_find_workspace_upwards() {
        let work_dir = assert_fs::TempDir::new().unwrap();
        assert!(Workspace::from(&work_dir).unwrap().is_none())
    }

    #[tokio::test]
    async fn test_luarc_path_custom_config() {
        let sample_project =
            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
        let project_root = assert_fs::TempDir::new().unwrap();
        project_root.copy_from(&sample_project, &["**"]).unwrap();

        let workspace = Workspace::from(&project_root).unwrap().unwrap();

        let config = ConfigBuilder::default()
            .luarc_file_name(Some("custom_config.json".to_string()))
            .build()
            .unwrap();

        let path = workspace.luarc_path(&config);
        assert_eq!(path, workspace.root().join("custom_config.json"));
    }

    #[tokio::test]
    async fn test_luarc_path_fallback_luarc() {
        let sample_project =
            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
        let project_root = assert_fs::TempDir::new().unwrap();
        project_root.copy_from(&sample_project, &["**"]).unwrap();

        let luarc_file = project_root.child(".luarc.json");
        luarc_file.touch().unwrap();

        let workspace = Workspace::from(&project_root).unwrap().unwrap();
        let config = ConfigBuilder::default().build().unwrap();

        let path = workspace.luarc_path(&config);
        assert_eq!(path, luarc_file.path().to_path_buf());
    }

    #[tokio::test]
    async fn test_luarc_path_fallback_emmyrc() {
        let sample_project =
            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
        let project_root = assert_fs::TempDir::new().unwrap();
        project_root.copy_from(&sample_project, &["**"]).unwrap();

        let emmyrc_file = project_root.child(".emmyrc.json");
        emmyrc_file.touch().unwrap();

        let workspace = Workspace::from(&project_root).unwrap().unwrap();
        let config = ConfigBuilder::default().build().unwrap();

        let path = workspace.luarc_path(&config);
        assert_eq!(path, emmyrc_file.path().to_path_buf());
    }

    #[tokio::test]
    async fn test_workspace_local_config() {
        let sample_workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("resources/test/sample-projects/multi-project/");
        let workspace_root = assert_fs::TempDir::new().unwrap();
        workspace_root
            .copy_from(&sample_workspace, &["**"])
            .unwrap();

        let workspace_config_dir = workspace_root.join(LUX_DIR_NAME);
        fs::tokio::create_dir_all(&workspace_config_dir)
            .await
            .unwrap();
        fs::tokio::write(
            workspace_config_dir.join(CONFIG_TOML),
            r#"
lua_version = "5.4"
generate_luarc = false
no_tfa = true
"#,
        )
        .await
        .unwrap();

        let workspace = Workspace::from(&workspace_root).unwrap().unwrap();
        let workspace_config = workspace.config().unwrap().unwrap();

        let config = ConfigBuilder::default()
            .lua_version(Some(LuaVersion::Lua53))
            .merge(workspace_config)
            .build()
            .unwrap();

        // The workspace-local config takes precedence over the base config,
        assert_eq!(config.lua_version(), Some(&LuaVersion::Lua54));
        assert!(!config.generate_luarc());
        assert!(config.no_tfa());

        // ... and unspecified keys keep the base/default values.
        assert_eq!(config.server().as_str(), "https://luarocks.org/");
    }
}
