// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

//! Test tools for building Fuchsia packages.

use {
    failure::{Error, ResultExt},
    fuchsia_component::client::{launcher, AppBuilder},
    fuchsia_merkle::Hash,
    fuchsia_pkg::MetaPackage,
    std::{
        collections::BTreeMap,
        fs::{self, File},
        io::{self, Write},
        path::{Path, PathBuf},
    },
    tempfile::TempDir,
};

/// A package generated by a [`PackageBuilder`], suitable for assembling into a TUF repository.
#[derive(Debug)]
pub struct Package {
    name: String,
    meta_far_merkle: Hash,
    artifacts: TempDir,
}

#[derive(Debug, PartialEq)]
enum PackageEntry {
    Directory,
    File(Vec<u8>),
}

impl PackageEntry {
    fn is_dir(&self) -> bool {
        match self {
            PackageEntry::Directory => true,
            _ => false,
        }
    }
}

impl Package {
    /// The merkle root of the package's meta.far.
    pub fn meta_far_merkle_root(&self) -> &Hash {
        &self.meta_far_merkle
    }

    /// The package's meta.far.
    pub fn meta_far(&self) -> io::Result<File> {
        File::open(self.artifacts.path().join("meta.far"))
    }

    /// The name of the package.
    pub fn name(&self) -> &str {
        self.name.as_str()
    }

    /// The directory containing the blobs contained in the package, including the meta.far.
    pub fn artifacts(&self) -> &Path {
        self.artifacts.path()
    }
}

/// A builder to simplify construction of Fuchsia packages.
pub struct PackageBuilder {
    name: String,
    contents: BTreeMap<PathBuf, PackageEntry>,
}

impl PackageBuilder {
    /// Creates a new `PackageBuilder`.
    pub fn new(name: impl Into<String>) -> Self {
        Self { name: name.into(), contents: BTreeMap::new() }
    }

    /// Create a subdirectory within the package.
    ///
    /// # Panics
    ///
    /// Panics if the package contains a file entry at `path` or any of its ancestors.
    pub fn dir(mut self, path: impl Into<PathBuf>) -> PackageDir {
        let path = path.into();
        self.make_dirs(&path);
        PackageDir::new(self, path)
    }

    /// Adds the provided `contents` to the package at the given `path`.
    ///
    /// # Panics
    ///
    /// Panics if either:
    /// * The package already contains a file or directory at `path`.
    /// * The package contains a file at any of `path`'s ancestors.
    pub fn add_resource_at(
        mut self,
        path: impl Into<PathBuf>,
        mut contents: impl io::Read,
    ) -> Result<Self, io::Error> {
        let path = path.into();
        let mut data = vec![];
        contents.read_to_end(&mut data)?;

        if let Some(parent) = path.parent() {
            self.make_dirs(parent);
        }
        let replaced = self.contents.insert(path.clone(), PackageEntry::File(data));
        assert_eq!(None, replaced, "already contains an entry at {:?}", path);
        Ok(self)
    }

    fn make_dirs(&mut self, path: &Path) {
        for ancestor in path.ancestors() {
            if ancestor == Path::new("") {
                continue;
            }
            assert!(
                self.contents
                    .entry(ancestor.to_owned())
                    .or_insert(PackageEntry::Directory)
                    .is_dir(),
                "{:?} is not a directory",
                ancestor
            );
        }
    }

    /// Builds the package.
    pub async fn build(self) -> Result<Package, Error> {
        // TODO Consider switching indir/packagedir to be a VFS instead

        // indir contains temporary inputs to package creation
        let indir = tempfile::tempdir().context("create /in")?;

        // packagedir contains outputs from package creation (manifest.json/meta.far) as well as
        // all blobs contained in the package. Pm will build the package manifest with absolute
        // source paths to /packages/{self.name}/contents/*.
        //
        // Sample layout of packagedir:
        // - {name}/
        // -   manifest.json
        // -   meta.far
        // -   contents/
        // -     file{N}

        let packagedir = tempfile::tempdir().context("create /packages")?;
        fs::create_dir(packagedir.path().join("contents")).context("create /packages/contents")?;
        let package_mount_path = format!("/packages/{}", &self.name);

        {
            let mut manifest = File::create(indir.path().join("package.manifest"))?;

            MetaPackage::from_name_and_variant(self.name.clone(), "0")?
                .serialize(File::create(indir.path().join("meta_package"))?)?;
            writeln!(manifest, "meta/package=/in/meta_package")?;

            for (i, (name, contents)) in self.contents.iter().enumerate() {
                let contents = match contents {
                    PackageEntry::File(data) => data,
                    _ => continue,
                };
                let path = format!("file{}", i);
                fs::write(packagedir.path().join("contents").join(&path), contents)?;
                writeln!(
                    manifest,
                    "{}={}/contents/{}",
                    &name.to_string_lossy(),
                    package_mount_path,
                    path
                )?;
            }
        }

        let fut = AppBuilder::new("fuchsia-pkg://fuchsia.com/pm#meta/pm.cmx")
            .arg(format!("-n={}", self.name))
            .arg("-version=0")
            .arg("-m=/in/package.manifest")
            .arg(format!("-o={}", package_mount_path))
            .arg("build")
            .arg("-depfile=false")
            .arg(format!("-output-package-manifest={}/manifest.json", &package_mount_path))
            .add_dir_to_namespace("/in".to_owned(), File::open(indir.path()).context("open /in")?)?
            .add_dir_to_namespace(
                package_mount_path.clone(),
                File::open(packagedir.path()).context("open /packages")?,
            )?
            .output(&launcher()?)?;
        fut.await?.ok()?;

        let meta_far_merkle =
            fs::read_to_string(packagedir.path().join("meta.far.merkle"))?.parse()?;

        // clean up after pm
        fs::remove_file(packagedir.path().join("meta/contents"))?;
        fs::remove_dir(packagedir.path().join("meta"))?;
        fs::remove_file(packagedir.path().join("meta.far.merkle"))?;

        Ok(Package { name: self.name, meta_far_merkle, artifacts: packagedir })
    }
}

/// A subdirectory of a package being built.
pub struct PackageDir {
    pkg: PackageBuilder,
    path: PathBuf,
}

impl PackageDir {
    fn new(pkg: PackageBuilder, path: impl Into<PathBuf>) -> Self {
        Self { pkg, path: path.into() }
    }

    /// Adds the provided `contents` to the package at the given `path`, relative to this
    /// `PackageDir`.
    ///
    /// # Panics
    /// If the package already contains a resource at `path`, relative to this `PackageDir`.
    pub fn add_resource_at(
        mut self,
        path: impl AsRef<Path>,
        contents: impl io::Read,
    ) -> Result<Self, io::Error> {
        self.pkg = self.pkg.add_resource_at(self.path.join(path.as_ref()), contents)?;
        Ok(self)
    }

    /// Finish adding resources to this directory, returning the modified [`PackageBuilder`].
    pub fn finish(self) -> PackageBuilder {
        self.pkg
    }
}

#[cfg(test)]
mod tests {
    use {super::*, fuchsia_merkle::MerkleTree, walkdir::WalkDir};

    #[test]
    #[should_panic(expected = r#""data" is not a directory"#)]
    fn test_panics_file_with_existing_parent_as_file() {
        let _ = (|| -> Result<(), Error> {
            PackageBuilder::new("test")
                .add_resource_at("data", "data contents".as_bytes())?
                .add_resource_at("data/foo", "data/foo contents".as_bytes())?;
            Ok(())
        })();
    }

    #[test]
    #[should_panic(expected = r#""data" is not a directory"#)]
    fn test_panics_dir_with_existing_file() {
        let _ = (|| -> Result<(), Error> {
            PackageBuilder::new("test")
                .add_resource_at("data", "data contents".as_bytes())?
                .dir("data");
            Ok(())
        })();
    }

    #[test]
    #[should_panic(expected = r#""data" is not a directory"#)]
    fn test_panics_nested_dir_with_existing_file() {
        let _ = (|| -> Result<(), Error> {
            PackageBuilder::new("test")
                .add_resource_at("data", "data contents".as_bytes())?
                .dir("data/foo");
            Ok(())
        })();
    }

    #[test]
    #[should_panic(expected = r#"already contains an entry at "data""#)]
    fn test_panics_file_with_existing_dir() {
        let _ = (|| -> Result<(), Error> {
            PackageBuilder::new("test")
                .dir("data")
                .add_resource_at("foo", "data/foo contents".as_bytes())?
                .finish()
                .add_resource_at("data", "data contents".as_bytes())?;
            Ok(())
        })();
    }

    #[fuchsia_async::run_singlethreaded(test)]
    async fn test_basic() -> Result<(), Error> {
        let pkg = PackageBuilder::new("rolldice")
            .dir("bin")
            .add_resource_at("rolldice", "asldkfjaslkdfjalskdjfalskdf".as_bytes())?
            .finish()
            .build()
            .await?;

        assert_eq!(
            pkg.meta_far_merkle,
            "7b3591496961a5b8918525feb2e49e6d1439580fd805a2b4178fc7581c011a0d".parse()?
        );
        assert_eq!(pkg.meta_far_merkle, MerkleTree::from_reader(pkg.meta_far()?)?.root());

        Ok(())
    }

    #[fuchsia_async::run_singlethreaded(test)]
    async fn test_dir_semantics() -> Result<(), Error> {
        let with_dir = PackageBuilder::new("data-file")
            .dir("data")
            .add_resource_at("file", "contents".as_bytes())?
            .finish()
            .build()
            .await?;

        let with_direct = PackageBuilder::new("data-file")
            .add_resource_at("data/file", "contents".as_bytes())?
            .build()
            .await?;

        assert_eq!(with_dir.meta_far_merkle_root(), with_direct.meta_far_merkle_root());

        Ok(())
    }

    fn is_generated_file(path: &Path) -> bool {
        match path.to_str() {
            Some("meta/contents") => true,
            Some("meta/package") => true,
            _ => false,
        }
    }

    #[fuchsia_async::run_singlethreaded(test)]
    async fn test_identity() -> Result<(), Error> {
        let mut pkg = PackageBuilder::new("fuchsia_pkg_testing_tests");

        // Add all non-generated files from this package into `pkg`.
        let this_package_root = Path::new("/pkg");

        for entry in WalkDir::new(this_package_root) {
            let entry = entry?;
            let path = entry.path();
            if !entry.file_type().is_file() || is_generated_file(path) {
                continue;
            }

            let relative_path = path.strip_prefix(this_package_root).unwrap();
            let f = File::open(path)?;
            pkg = pkg.add_resource_at(relative_path.to_str().unwrap(), f)?;
        }

        let pkg = pkg.build().await?;

        assert_eq!(pkg.meta_far_merkle, MerkleTree::from_reader(pkg.meta_far()?)?.root());

        // Verify the generated package's merkle root is the same as this test package's merkle root.
        assert_eq!(pkg.meta_far_merkle, fs::read_to_string("/pkg/meta")?.parse()?);

        Ok(())
    }
}
