pub mod bundle;


use std::{ops::Not, path::PathBuf};

use tracing::debug;

use crate::{distribution::bundle::dir::DirBundler, error::{DistributionError, DistributionErrorSource, DistributionResult}, fs, staging::{StagingDir, StagingDirOptions}, DistributionOptions};
use bundle::{BundleMetadata, Bundler};


/// Bundle files into a directory or archive for providing a release build of your application to your users.
///
/// This data type provides the entrypoint for defining a predictable file structure, without needing separate validation code.
/// You can call the functions of [StagingDir] on this data type for defining the file structure.
///
/// The build steps are sandboxed, allowing you to clear out build directories without affecting the rest of the distribution.
/// But if you do not clear out the contents of the build directory, the contents are preserved between builds,
/// to allow caching the build outputs, either as provided by external tools, or via [cicero::cache](https://crates.io/crates/cicero_cache).
pub struct Distribution {
    /// The name of the distribution provided in [Distribution::new].
    /// Used for resulting distribution folder or archive name.
    pub name: String,
    root: StagingDir,
    pub(super) out_dir: PathBuf,
}
impl Distribution {

    /// Initiate a new distribution.
    ///
    /// The given name will be used to name the resulting distribution folder or archive.
    pub fn new(name: impl Into<String>) -> DistributionResult<Self> {
        Self::new_with_options(
            name,
            DistributionOptions::default(),
        )
    }

    /// Initiate a new distribution with additional options.
    ///
    /// The given name will be used to name the resulting distribution directory or archive.
    /// The provided [DistributionOptions] configures how the distribution is built.
    pub fn new_with_options(name: impl Into<String>, options: DistributionOptions) -> DistributionResult<Self> {

        let build_dir = cicero_path_core::cicero::internal().join("distribution").join("work");
        let staging_dir = cicero_path_core::cicero::internal().join("distribution").join("staging");
        let out_dir = cicero_path_core::cicero::target().join("distribution");

        Self::new_with_options_and_dirs(
            name,
            options,
            DistributionDirs { build_dir, staging_dir, out_dir },
        )
    }

    /// Finalize the distribution and bundle it for providing it to your users.
    ///
    /// ```
    /// use cicero_distribution::{bundle::dir::DirBundler, Distribution};
    ///
    ///# fn main() -> cicero_distribution::Result<()> {
    /// let distribution = Distribution::new("my-project")?;
    ///
    /// //add files to the distribution
    ///
    /// let out_file = distribution.bundle(DirBundler)?;
    ///
    ///# Ok(())
    ///# }
    /// ```
    /// See the [bundle] module for the built-in bundlers.
    pub fn bundle(&self, bundler: impl Bundler) -> crate::Result<PathBuf> {
        let out_dir = &self.out_dir;

        fs::create_dir_all(out_dir)
            .map_err(|cause| DistributionError::bundle(&self.name, cause))?;

        let out_path = {
            let name = &self.name;
            let extension = bundler.file_extension();
            out_dir.join(format!("{name}{extension}"))
        };

        cicero_cache::Output::from(out_path.clone())
            .rebuild_on_change(
                &[self.root.path().to_path_buf()],
                || {
                    let metadata = BundleMetadata {
                        distribution_name: self.name.clone(),
                    };

                    bundler.bundle(self.root.path(), &out_path, &metadata)
                }
            )
            .map_err(|source| DistributionError::bundle(
                &self.name,
                DistributionErrorSource::Bundler {
                    bundler: std::any::type_name_of_val(&bundler),
                    source,
                }
            ))?;

        Ok(out_path)
    }

    /// Finalize the distribution and copy it into its output directory.
    ///
    /// This does not put it into an archive file.
    /// See the additional crate features, if you want an archive instead.
    #[deprecated(since="0.7.0", note="Use `.bundle(DirBundler)` instead.")]
    pub fn bundle_as_dir(&self) -> crate::Result<PathBuf> {
        self.bundle(DirBundler)
    }

    /// Finalize the distribution and bundle it into a .tar.gz archive.
    #[cfg(feature = "tar_gz")]
    #[deprecated(since="0.7.0", note="Use `.bundle(TarBundler::default())` instead.")]
    pub fn bundle_as_tar_gz(&self) -> DistributionResult<PathBuf> {
        use crate::distribution::bundle::tar::TarBundler;

        self.bundle(TarBundler::default())
    }



    fn new_with_options_and_dirs(name: impl Into<String>, options: DistributionOptions, dirs: DistributionDirs) -> DistributionResult<Self> {
        let name = name.into();
        assert!(name.is_empty().not(), "Distribution name cannot be empty.");

        let DistributionOptions { filter } = options;
        let DistributionDirs { build_dir, staging_dir, out_dir } = dirs;

        let build_dir = build_dir.join(&name);
        let staging_dir = staging_dir.join(&name);
        let distribution_root_dir = staging_dir.clone();


        cicero_cache::Output::from(PathBuf::from(file!())) //Currently need to specify a path here. This is a no-op path, since Cicero is a dependency of the user's build script anyways, which already gets monitored for changes by default.
            .rebuild_on_change([], || { //empty inputs, because we only want changes to the build script to trigger a rebuild (which is checked in cache::Output)
                debug!("Build script changed. Rebuilding distribution without cache.");

                if build_dir.exists() {
                    fs::remove_dir_all(&build_dir)?;
                }
                fs::create_dir_all(&build_dir)?;
                Ok(())
            }).map_err(|cause| DistributionError::create(&name, DistributionErrorSource::CleanDistributionFiles(cause)))?;


        Ok(Self {
            name: name.to_owned(),
            root: StagingDir::create_with_options(StagingDirOptions {
                build_dir,
                staging_dir,
                distribution_root_dir,
                filter,
            }).map_err(|cause| DistributionError::create(name, cause))?,
            out_dir,
        })
    }
}
impl std::ops::Deref for Distribution {
    type Target = StagingDir;

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

struct DistributionDirs {
    build_dir: PathBuf,
    staging_dir: PathBuf,
    out_dir: PathBuf,
}


#[cfg(test)]
mod tests {
    use super::*;

    use std::{fs::File, path::Path};

    use crate::filter::DistributionFilter;

    use cicero_core::error::Context as _;
    use assert_fs::{fixture::ChildPath, prelude::*};
    use predicates::prelude::*;

    #[test]
    fn should_copy_files() -> cicero_core::Result<()> {
        let fixture = DistributionFixture::create()?;
        let distribution = fixture.testee(DistributionOptions::default())?;

        distribution
            .add_file("file1", |file| fs::write(&file, "file1").context("Adding file 'file1'"))?;
        distribution
            .add_file("file2", |file| fs::write(&file, "file2").context("Adding file 'file2'"))?;

        distribution
            .dir("subdir")?
            .add_all(|dir|
                sub_dir_contents(dir).context("Adding everything for sub dir")
            )?;

        let out_dir = distribution.bundle(DirBundler)?;


        assert!(predicate::path::is_file().eval(&out_dir.join("file1")));
        assert!(predicate::path::is_file().eval(&out_dir.join("file2")));

        let sub_dir = out_dir.join("subdir");
        assert!(predicate::path::is_dir().eval(&sub_dir));

        let subdir_contents = fs::read_dir(sub_dir)?.collect::<Vec<_>>();
        assert!(subdir_contents.is_empty().not());

        Ok(())
    }

    #[test]
    fn should_filter_distribution() -> cicero_core::Result<()> {
        let fixture = DistributionFixture::create()?;
        let distribution = fixture.testee(DistributionOptions {
            filter: DistributionFilter::Enabled(vec![glob::Pattern::new("public/to_be_included*")?]),
        })?;

        distribution.dir("public")?
            .add_file("not_included.txt", |path| { File::create(path)?; Ok(()) })?
            .add_file("to_be_included.txt", |path| { File::create(path)?; Ok(()) })?;

        distribution.dir("something_else")?
            .add_file("some_other_content.txt", |path| { File::create(path)?; Ok(()) })?;

        let out_dir = distribution.bundle(DirBundler)?;


        let mut dir_contents = fs::read_dir(&out_dir)?
            .map(|entry| entry.unwrap().path());

        assert_eq!(dir_contents.next(), Some(out_dir.join("public")));
        assert_eq!(dir_contents.next(), None);

        let mut sub_dir_contents = fs::read_dir(&out_dir.join("public"))?
            .map(|entry| entry.unwrap().path());
        assert_eq!(sub_dir_contents.next(), Some(out_dir.join("public/to_be_included.txt")));
        assert_eq!(sub_dir_contents.next(), None);

        Ok(())
    }

    fn sub_dir_contents(out_dir: &Path) -> cicero_core::Result<()> {
        let sub_file = out_dir.join("subdir_file");
        fs::write(&sub_file, "subdir-file")?;

        Ok(())
    }

    pub struct DistributionFixture {
        build_dir: ChildPath,
        staging_dir: ChildPath,
        out_dir: ChildPath,
        /// Carried along, so it's only dropped at the end of the tests
        _temp_dir: assert_fs::TempDir,
    }
    impl DistributionFixture {
        pub fn create() -> cicero_core::Result<Self> {
            let _temp_dir = assert_fs::TempDir::new()?;

            let build_dir = _temp_dir.child("work");
            let staging_dir = _temp_dir.child("staging");
            let out_dir = _temp_dir.child("out");

            Ok(Self {
                build_dir,
                staging_dir,
                out_dir,
                _temp_dir,
            })
        }
        pub fn testee(&self, options: DistributionOptions) -> DistributionResult<Distribution> {
            Distribution::new_with_options_and_dirs(
                String::from("test-distribution"),
                options,
                DistributionDirs {
                    build_dir: self.build_dir.to_path_buf(),
                    staging_dir: self.staging_dir.to_path_buf(),
                    out_dir: self.out_dir.to_path_buf()
                }
            )
        }
    }
}
