//! Add files into a distribution.

use std::{ops::Not, path::{Path, PathBuf}, sync::atomic::{AtomicUsize, Ordering}};

use cicero_core::error::Context as _;
use tracing::debug;
use crate::{error::{DistributionError, DistributionErrorSource, DistributionResult}, fs};

use crate::filter::DistributionFilter;


/// API data type for defining the file structure of your distribution and placing files into it.
///
/// Constructed by creating a [`crate::distribution::Distribution`].
pub struct StagingDir {
    options: StagingDirOptions,
    add_all_counter: AtomicUsize,
}

impl StagingDir {
    /// Add a sub-directory and return a new `StagingDir` instance for it.
    /// ```
    /// use cicero_distribution::Distribution;
    ///
    /// fn main() -> cicero_core::Result<()> {
    ///     let distribution = Distribution::new("my-program")?;
    ///
    ///     let assets_dir = distribution.dir("assets")?;
    ///     Ok(())
    /// }
    /// ```
    pub fn dir(&self, name: impl AsRef<str>) -> DistributionResult<StagingDir> {
        let name = name.as_ref();

        let build_dir = self.options.build_dir.join(name);
        let staging_dir = self.path().join(name);

        let filter = self.options.filter.clone();

        let staging_dir = StagingDir::create_with_options(
            StagingDirOptions {
                build_dir,
                staging_dir,
                distribution_root_dir: self.options.distribution_root_dir.clone(),
                filter,
            }
        )?;
        Ok(staging_dir)
    }

    /// Add a named file to this `StagingDir`.
    ///
    /// # Usage
    /// As first parameter, provide a name to be used for the file in the distribution.
    /// The second parameter is a closure to call when building the distribution. It will get a path passed in, to where you write the file.
    ///
    /// ```
    /// use cicero_distribution::Distribution;
    ///
    /// fn main() -> cicero_core::Result<()> {
    ///     let distribution = Distribution::new("my-program")?;
    ///
    ///     let assets_dir = distribution.dir("assets")?;
    ///
    ///     assets_dir
    ///         .add_file("favicon.png", |file| render_favicon(file))?
    ///         .add_file("favicon2.png", |file| render_favicon(file))?;
    ///
    ///     Ok(())
    /// }
    ///
    /// fn render_favicon(path: &std::path::Path) -> cicero_core::Result<()> {
    ///     std::fs::write(path, b"12345678")?;
    ///     Ok(())
    /// }
    /// ```
    ///
    /// The path passed into the closure is not the final path in the distribution, but rather for an isolated build file.
    /// This means you can delete this build file, as you see fit, without erasing parts of the distribution.
    /// The provided file will be copied into the distribution during the bundling step at the end.
    pub fn add_file(&self, name: impl AsRef<str>, code: impl FnOnce(&Path) -> cicero_core::Result<()>) -> DistributionResult<&StagingDir> {
        let name = name.as_ref();

        let staging_dir = self.path();
        let staging_file = &staging_dir.join(name);
        let relative_staging_file = staging_file.strip_prefix(&self.options.distribution_root_dir)
            .map_err(|cause| DistributionError::stage_from_build(staging_file, cause))?;

        if self.options.filter.matches_file(relative_staging_file) {

            //isolate into separate dir, so build code can clear this dir without affecting staging
            let path_hash = fs::path_hash(staging_file);
            let task_build_file = &self.options.build_dir
                .join(path_hash);


            code(task_build_file)
                .map_err(|cause| DistributionError::stage_from_build(relative_staging_file, DistributionErrorSource::UserScript(cause)))?;


            fs::create_dir_all(staging_dir) //explicitly create, in case the parent directories were filtered out
                .map_err(|cause| DistributionError::stage_from_build(staging_file, cause))?;

            fs::copy(task_build_file, staging_file)
                .map_err(|cause| DistributionError::stage_from_build(staging_file, cause))?;

            assert!(staging_file.exists(), "Error while adding file: File {relative_staging_file:?} does not exist after running creation code.");
        } else {
            debug!("Skipping inclusion of file {relative_staging_file:?}, because it does not match filter: {:?}", self.options.filter);
        }

        Ok(self)
    }

    /// Add a set of files into this `StagingDir`.
    ///
    /// Generally, you should prefer [`StagingDir::add_file`], unless you have to add the build output of another tool,
    /// which produces multiple files, since [`StagingDir::add_file`] produces a more predictable file structure.
    ///
    /// # Usage
    /// Provide a closure to call when building the distribution.
    /// The closure will get a path to a directory passed in, into which you write any files you want to include.
    ///
    /// ```
    /// use cicero_distribution::Distribution;
    ///
    /// fn main() -> cicero_core::Result<()> {
    ///     let distribution = Distribution::new("my-program")?;
    ///
    ///     let assets_dir = distribution.dir("assets")?;
    ///
    ///     assets_dir
    ///         .add_all(|dir| build_frontend(dir))?;
    ///
    ///     Ok(())
    /// }
    ///
    /// fn build_frontend(dir: &std::path::Path) -> cicero_core::Result<()> {
    ///     // Call external build tool, e.g.:
    ///     // Command::new("trunk").arg("build")
    ///     //     .arg("--dist").arg(dir);
    ///     Ok(())
    /// }
    /// ```
    ///
    /// The path passed into the closure is not the final path in the distribution, but rather for an isolated build directory.
    /// This means you can clear out this build directory, as you see fit, without erasing parts of the distribution.
    /// The provided directory contents will be copied into the distribution during the bundling step at the end.
    pub fn add_all(&self, code: impl FnOnce(&Path) -> cicero_core::Result<()>) -> DistributionResult<&StagingDir> {
        let staging_dir = &self.path();
        let relative_staging_dir = staging_dir.strip_prefix(&self.options.distribution_root_dir)
            .map_err(|cause| DistributionError::stage_from_build(staging_dir, cause))?;

        //Use unique name for build dir to offer isolation from other tasks
        let build_dir_name = format!("add-all-{}", self.add_all_counter.fetch_add(1, Ordering::Relaxed)); //Relaxed ordering should be fine for a simple counter.

        if self.options.filter.matches_dir(relative_staging_dir) {

            //isolate into separate dir, so build code can clear this dir without affecting staging
            let path_hash = fs::path_hash(staging_dir);
            let task_build_dir = &self.options.build_dir
                .join(format!("{path_hash}-{build_dir_name}"));

            fs::create_dir_all(task_build_dir)
                .map_err(|cause| DistributionError::stage_from_build(staging_dir, cause))?;

            code(task_build_dir)
                .context(format!("Error while running user-provided creation code for contents of distribution dir {relative_staging_dir:?}"))
                .map_err(|cause| DistributionError::stage_from_build(staging_dir, DistributionErrorSource::UserScript(cause)))?;

            fs::copy_dir_filtered(
                task_build_dir,
                staging_dir,
                &self.options.filter,
                Some(&self.options.distribution_root_dir),
            ).map_err(|cause| DistributionError::stage_from_build(staging_dir, cause))?;
        } else {
            debug!("Skipping inclusion of directory {relative_staging_dir:?}, because it does not match filter: {:?}", self.options.filter);
        }

        Ok(self)
    }


    /// Includes a file from the given `file` under the given `name`.
    ///
    /// This function is intended for static files.
    /// For files that need to be generated during the build, you generally want [`StagingDir::add_file`] instead.
    ///
    /// ```
    /// use cicero_distribution::{bundle::dir::DirBundler, Distribution};
    /// use cicero_path::repo_path;
    ///
    /// fn main() -> cicero_core::Result<()> {
    ///     let distribution = Distribution::new("my-program")?;
    ///
    ///     distribution
    ///         .add_file_from_path("README.md", repo_path!("README.md"))?;
    ///
    ///     let path = distribution.bundle(DirBundler);
    ///     Ok(())
    /// }
    /// ```
    ///
    /// This function fails, if the target file already exists.
    pub fn add_file_from_path(&self, name: impl AsRef<str>, file: impl AsRef<Path>) -> DistributionResult<&StagingDir> {
        let source_file = file.as_ref();
        let target_file = &self.path().join(name.as_ref());

        if source_file.exists().not() {
            Err(DistributionError::stage_from_path(source_file, target_file, "the source file does not exist"))?;
        }
        if source_file.is_file().not() {
            Err(DistributionError::stage_from_path(source_file, target_file, "the source is not a file"))?;
        }
        if target_file.exists() {
            Err(DistributionError::stage_from_path(source_file, target_file, "the target already exists"))?;
        }

        fs::copy(source_file, target_file)
            .map_err(|cause| DistributionError::stage_from_path(source_file, target_file, cause))?;

        Ok(self)
    }

    /// Recursively includes the contents of the given `directory`.
    ///
    /// This function is intended for static files.
    /// For files that need to be generated during the build, you generally want [`StagingDir::add_all`] instead.
    ///
    /// ```
    /// use cicero_distribution::{bundle::dir::DirBundler, Distribution};
    /// use cicero_path::repo_path;
    ///
    /// fn main() -> cicero_core::Result<()> {
    ///     let distribution = Distribution::new("my-program")?;
    ///
    ///     distribution
    ///         .dir("doc")?
    ///         .add_all_from_path(repo_path!("doc/"))?;
    ///
    ///     let path = distribution.bundle(DirBundler);
    ///     Ok(())
    /// }
    /// ```
    ///
    /// This function fails, when one of the source paths collides with a previously included path.
    pub fn add_all_from_path(&self, directory: impl AsRef<Path>) -> DistributionResult<&StagingDir> {
        let source_dir = directory.as_ref();
        let target_dir = &self.path();

        if source_dir.exists().not() {
            Err(DistributionError::stage_from_path(source_dir, target_dir, "the source dir does not exist"))?;
        }
        if source_dir.is_dir().not() {
            Err(DistributionError::stage_from_path(source_dir, target_dir, "the source is not a directory"))?;
        }

        fs::copy_dir_all(source_dir, target_dir)
            .map_err(|cause| DistributionError::stage_from_path(source_dir, target_dir, cause))?;

        Ok(self)
    }


    pub(super) fn path(&self) -> &Path {
        &self.options.staging_dir
    }


    pub(super) fn create_with_options(options: StagingDirOptions) -> DistributionResult<Self> {

        if options.staging_dir.exists() {
            debug!("Clearing staging directory before collecting distribution files. Staging directory is: {:?}", options.staging_dir);
            fs::remove_dir_all(&options.staging_dir)
                .map_err(|cause| DistributionError::create_staging(&options.staging_dir, cause))?;
        }

        let relative_staging_dir = options.staging_dir.strip_prefix(&options.distribution_root_dir)
            .map_err(|cause| DistributionError::create_staging(&options.staging_dir, cause))?;

        if options.filter.matches_dir(relative_staging_dir) {

            fs::create_dir_all(&options.staging_dir)
                .map_err(|cause| DistributionError::create_staging(&options.staging_dir, cause))?;

            fs::create_dir_all(&options.build_dir)
                .map_err(|cause| DistributionError::create_staging(&options.staging_dir, cause))?;
        }

        Ok(Self {
            options,
            add_all_counter: AtomicUsize::default(),
        })
    }
}

pub(super) struct StagingDirOptions {
    pub build_dir: PathBuf,
    pub staging_dir: PathBuf,
    pub distribution_root_dir: PathBuf,
    pub filter: DistributionFilter,
}


#[cfg(test)]
mod tests {
    use super::*;
    use std::ops::Not;
    use crate::filter::DistributionFilter;
    use assert_fs::{fixture::ChildPath, prelude::*};


    mod from_build {
        use super::*;

        #[test]
        fn should_provide_correct_path() -> cicero_core::Result<()> {
            let Fixture { testee, _temp_dir, source_dir: _ } = Fixture::create(DistributionFilter::Disabled)?;

            testee
                .dir("test")?
                .add_all(|dir| {
                    let add_all_dir_name = dir.file_name().unwrap().to_string_lossy();
                    assert!(add_all_dir_name.len() >= 64); //contains hash
                    assert!(add_all_dir_name.ends_with("-add-all-0"));

                    let dir = dir.parent().unwrap();
                    assert_eq!(dir, testee.options.build_dir.join("test"));

                    Ok(())
                })?
                .add_all(|dir| {
                    let add_all_dir_name = dir.file_name().unwrap().to_string_lossy();
                    assert!(add_all_dir_name.len() >= 64); //contains hash
                    assert!(add_all_dir_name.ends_with("-add-all-1")); //repeated calls to .add_all() are discerned by incrementing the number (the number should not collide when the user re-orders calls, because we invalidate the cache on build script changes)
                    Ok(())
                })?
                .add_file("file1", |file| {
                    assert_eq!(file.parent().unwrap(), testee.options.build_dir.join("test"));

                    fs::File::create(file)?; //need to create a file, because production code asserts this
                    Ok(())
                })?;

            Ok(())
        }

        #[test]
        fn should_isolate_build_dir_between_tasks() -> cicero_core::Result<()> {
            let Fixture { testee, _temp_dir, source_dir: _ } = Fixture::create(DistributionFilter::Disabled)?;

            testee
                .dir("test")?
                .add_all(|dir| {
                    fs::File::create(dir.join("file1"))?; //create file which would conflict, if built in the same directory
                    Ok(())
                })?
                .add_file("file1", |file| {
                    assert!(file.exists().not());

                    fs::File::create(file)?; //need to create a file, because production code asserts this
                    Ok(())
                })?;

            Ok(())
        }

        #[test]
        fn should_filter_add_all() -> cicero_core::Result<()> {
            let Fixture { testee, _temp_dir, source_dir: _ } = Fixture::create(DistributionFilter::Enabled(vec![
                glob::Pattern::new("dir/included.txt")?,
            ]))?;

            testee
                .dir("dir")?
                .add_all(|dir| {
                    fs::File::create(dir.join("included.txt"))?;
                    fs::File::create(dir.join("excluded.txt"))?;
                    Ok(())
                })?;

            let dir_path = testee.options.staging_dir.join("dir");
            let mut dir_contents = fs::read_dir(&dir_path)?
                .map(|entry| entry.unwrap().path());

            assert_eq!(dir_contents.next(), Some(dir_path.join("included.txt")));
            assert_eq!(dir_contents.next(), None);

            Ok(())
        }
    }



    mod from_path {
        use super::*;

        #[test]
        fn add_file_from_path_should_refuse_on_conflicts() -> cicero_core::Result<()> {
            let Fixture { testee, source_dir, _temp_dir } = Fixture::create(DistributionFilter::Disabled)?;

            let result = testee
                .add_file("file1", |file| { fs::write(file, "file1-content")?; Ok(()) });
            assert!(result.is_ok());

            let result = testee
                .add_file_from_path("file1", create_file("file2-out", source_dir.path())?);
            assert!(result.is_err());

            Ok(())
        }

        #[test]
        fn add_all_from_path_method_should_refuse_on_conflicts() -> cicero_core::Result<()> {
            let Fixture { testee, source_dir, _temp_dir } = Fixture::create(DistributionFilter::Disabled)?;

            let work_subdir = create_sub_dir(source_dir.path())?;


            let sub_dir = testee.dir("subdir")?;
            let result = sub_dir
                .add_all_from_path(&work_subdir);
            assert!(result.is_ok());

            let result = sub_dir
                .add_all_from_path(&work_subdir);
            assert!(result.is_err());

            Ok(())
        }


        fn create_sub_dir(work_dir: &Path) -> cicero_core::Result<PathBuf> {
            let out_dir = work_dir.join("subdir/");
            fs::create_dir(&out_dir)?;

            let sub_file = out_dir.join("subdir_file");
            fs::write(&sub_file, "subdir-file")?;

            Ok(out_dir)
        }

        fn create_file(name: &str, work_dir: &Path) -> cicero_core::Result<PathBuf> {
            let out_file = work_dir.join(name);
            fs::write(&out_file, name)?;
            Ok(out_file)
        }
    }


    struct Fixture {
        testee: StagingDir,
        source_dir: ChildPath,
        /// Carried along, so it's only dropped at the end of the tests
        _temp_dir: assert_fs::TempDir,
    }
    impl Fixture {
        fn create(filter: DistributionFilter) -> cicero_core::Result<Self> {
            let _temp_dir = assert_fs::TempDir::new()?;

            let source_dir = _temp_dir.child("source");
            source_dir.create_dir_all()?;

            let build_dir = _temp_dir.child("build").to_path_buf();
            let staging_dir = _temp_dir.child("staging").to_path_buf();
            let distribution_root_dir = staging_dir.clone();

            let testee = StagingDir::create_with_options(StagingDirOptions {
                build_dir,
                staging_dir,
                distribution_root_dir,
                filter,
            })?;

            Ok(Self {
                testee,
                source_dir,
                _temp_dir,
            })
        }
    }
}
