//! Support for compiling contracts
use crate::{
    artifacts::Sources,
    config::{ProjectPaths, SolcConfig},
    error::{Result, SolcError},
    filter::{FilteredSource, FilteredSourceInfo, FilteredSources},
    resolver::GraphEdges,
    utils, ArtifactFile, ArtifactOutput, Artifacts, ArtifactsMap, OutputContext, Project,
    ProjectPathsConfig, Source,
};
use semver::Version;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use std::{
    collections::{
        btree_map::{BTreeMap, Entry},
        hash_map, BTreeSet, HashMap, HashSet,
    },
    fs::{self},
    path::{Path, PathBuf},
    time::{Duration, UNIX_EPOCH},
};

/// ethers-rs format version
///
/// `ethers-solc` uses a different format version id, but the actual format is consistent with
/// hardhat This allows ethers-solc to detect if the cache file was written by hardhat or
/// `ethers-solc`
const ETHERS_FORMAT_VERSION: &str = "ethers-rs-sol-cache-3";

/// The file name of the default cache file
pub const SOLIDITY_FILES_CACHE_FILENAME: &str = "solidity-files-cache.json";

/// A multi version cache file
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct SolFilesCache {
    #[serde(rename = "_format")]
    pub format: String,
    /// contains all directories used for the project
    pub paths: ProjectPaths,
    pub files: BTreeMap<PathBuf, CacheEntry>,
}

impl SolFilesCache {
    /// Create a new cache instance with the given files
    pub fn new(files: BTreeMap<PathBuf, CacheEntry>, paths: ProjectPaths) -> Self {
        Self { format: ETHERS_FORMAT_VERSION.to_string(), files, paths }
    }

    pub fn is_empty(&self) -> bool {
        self.files.is_empty()
    }

    /// How many entries the cache contains where each entry represents a sourc file
    pub fn len(&self) -> usize {
        self.files.len()
    }

    /// How many `Artifacts` this cache references, where a source file can have multiple artifacts
    pub fn artifacts_len(&self) -> usize {
        self.entries().map(|entry| entry.artifacts().count()).sum()
    }

    /// Returns an iterator over all `CacheEntry` this cache contains
    pub fn entries(&self) -> impl Iterator<Item = &CacheEntry> {
        self.files.values()
    }

    /// Returns the corresponding `CacheEntry` for the file if it exists
    pub fn entry(&self, file: impl AsRef<Path>) -> Option<&CacheEntry> {
        self.files.get(file.as_ref())
    }

    /// Returns the corresponding `CacheEntry` for the file if it exists
    pub fn entry_mut(&mut self, file: impl AsRef<Path>) -> Option<&mut CacheEntry> {
        self.files.get_mut(file.as_ref())
    }

    /// Reads the cache json file from the given path
    ///
    /// See also [`Self::read_joined()`]
    ///
    /// # Errors
    ///
    /// If the cache file does not exist
    ///
    /// # Example
    ///
    /// ```
    /// # fn t() {
    /// use ethers_solc::cache::SolFilesCache;
    /// use ethers_solc::Project;
    ///
    /// let project = Project::builder().build().unwrap();
    /// let mut cache = SolFilesCache::read(project.cache_path()).unwrap();
    /// cache.join_artifacts_files(project.artifacts_path());
    /// # }
    /// ```
    #[tracing::instrument(skip_all, name = "sol-files-cache::read")]
    pub fn read(path: impl AsRef<Path>) -> Result<Self> {
        let path = path.as_ref();
        tracing::trace!("reading solfiles cache at {}", path.display());
        let cache: SolFilesCache = utils::read_json_file(path)?;
        tracing::trace!("read cache \"{}\" with {} entries", cache.format, cache.files.len());
        Ok(cache)
    }

    /// Reads the cache json file from the given path and returns the cache with paths adjoined to
    /// the `ProjectPathsConfig`.
    ///
    /// This expects the `artifact` files to be relative to the artifacts dir of the `paths` and the
    /// `CachEntry` paths to be relative to the root dir of the `paths`
    ///
    ///
    ///
    /// # Example
    ///
    /// ```
    /// # fn t() {
    /// use ethers_solc::cache::SolFilesCache;
    /// use ethers_solc::Project;
    ///
    /// let project = Project::builder().build().unwrap();
    /// let cache = SolFilesCache::read_joined(&project.paths).unwrap();
    /// # }
    /// ```
    pub fn read_joined(paths: &ProjectPathsConfig) -> Result<Self> {
        let mut cache = SolFilesCache::read(&paths.cache)?;
        cache.join_entries(&paths.root).join_artifacts_files(&paths.artifacts);
        Ok(cache)
    }

    /// Write the cache as json file to the given path
    pub fn write(&self, path: impl AsRef<Path>) -> Result<()> {
        let path = path.as_ref();
        utils::create_parent_dir_all(path)?;
        let file = fs::File::create(path).map_err(|err| SolcError::io(err, path))?;
        tracing::trace!(
            "writing cache with {} entries to json file: \"{}\"",
            self.len(),
            path.display()
        );
        serde_json::to_writer_pretty(file, self)?;
        tracing::trace!("cache file located: \"{}\"", path.display());
        Ok(())
    }

    /// Sets the `CacheEntry`'s file paths to `root` adjoined to `self.file`.
    pub fn join_entries(&mut self, root: impl AsRef<Path>) -> &mut Self {
        let root = root.as_ref();
        self.files = std::mem::take(&mut self.files)
            .into_iter()
            .map(|(path, entry)| (root.join(path), entry))
            .collect();
        self
    }

    /// Removes `base` from all `CacheEntry` paths
    pub fn strip_entries_prefix(&mut self, base: impl AsRef<Path>) -> &mut Self {
        let base = base.as_ref();
        self.files = std::mem::take(&mut self.files)
            .into_iter()
            .map(|(path, entry)| (path.strip_prefix(base).map(Into::into).unwrap_or(path), entry))
            .collect();
        self
    }

    /// Sets the artifact files location to `base` adjoined to the `CachEntries` artifacts.
    pub fn join_artifacts_files(&mut self, base: impl AsRef<Path>) -> &mut Self {
        let base = base.as_ref();
        self.files.values_mut().for_each(|entry| entry.join_artifacts_files(base));
        self
    }

    /// Removes `base` from all artifact file paths
    pub fn strip_artifact_files_prefixes(&mut self, base: impl AsRef<Path>) -> &mut Self {
        let base = base.as_ref();
        self.files.values_mut().for_each(|entry| entry.strip_artifact_files_prefixes(base));
        self
    }

    /// Removes all `CacheEntry` which source files don't exist on disk
    ///
    /// **NOTE:** this assumes the `files` are absolute
    pub fn remove_missing_files(&mut self) {
        tracing::trace!("remove non existing files from cache");
        self.files.retain(|file, _| {
            let exists = file.exists();
            if !exists {
                tracing::trace!("remove {} from cache", file.display());
            }
            exists
        })
    }

    /// Checks if all artifact files exist
    pub fn all_artifacts_exist(&self) -> bool {
        self.files.values().all(|entry| entry.all_artifacts_exist())
    }

    /// Strips the given prefix from all `file` paths that identify a `CacheEntry` to make them
    /// relative to the given `base` argument
    ///
    /// In other words this sets the keys (the file path of a solidity file) relative to the `base`
    /// argument, so that the key `/Users/me/project/src/Greeter.sol` will be changed to
    /// `src/Greeter.sol` if `base` is `/Users/me/project`
    ///
    /// # Example
    ///
    /// ```
    /// # fn t() {
    /// use ethers_solc::artifacts::contract::CompactContract;
    /// use ethers_solc::cache::SolFilesCache;
    /// use ethers_solc::Project;
    /// let project = Project::builder().build().unwrap();
    /// let cache = SolFilesCache::read(project.cache_path())
    ///     .unwrap()
    ///     .with_stripped_file_prefixes(project.root());
    /// let artifact: CompactContract = cache.read_artifact("src/Greeter.sol", "Greeter").unwrap();
    /// # }
    /// ```
    ///
    /// **Note:** this only affects the source files, see [`Self::strip_artifact_files_prefixes()`]
    pub fn with_stripped_file_prefixes(mut self, base: impl AsRef<Path>) -> Self {
        let base = base.as_ref();
        self.files = self
            .files
            .into_iter()
            .map(|(f, e)| (utils::source_name(&f, base).to_path_buf(), e))
            .collect();
        self
    }

    /// Returns the path to the artifact of the given `(file, contract)` pair
    ///
    /// # Example
    ///
    /// ```
    /// # fn t() {
    /// use ethers_solc::cache::SolFilesCache;
    /// use ethers_solc::Project;
    ///
    /// let project = Project::builder().build().unwrap();
    /// let cache = SolFilesCache::read_joined(&project.paths).unwrap();
    /// cache.find_artifact_path("/Users/git/myproject/src/Greeter.sol", "Greeter");
    /// # }
    /// ```
    pub fn find_artifact_path(
        &self,
        contract_file: impl AsRef<Path>,
        contract_name: impl AsRef<str>,
    ) -> Option<&PathBuf> {
        let entry = self.entry(contract_file)?;
        entry.find_artifact_path(contract_name)
    }

    /// Finds the path to the artifact of the given `(file, contract)` pair, see
    /// [`Self::find_artifact_path()`], and reads the artifact as json file
    /// # Example
    ///
    /// ```
    /// fn t() {
    /// use ethers_solc::cache::SolFilesCache;
    /// use ethers_solc::Project;
    /// use ethers_solc::artifacts::contract::CompactContract;
    ///
    /// let project = Project::builder().build().unwrap();
    /// let cache = SolFilesCache::read_joined(&project.paths).unwrap();
    /// let artifact: CompactContract = cache.read_artifact("/Users/git/myproject/src/Greeter.sol", "Greeter").unwrap();
    /// # }
    /// ```
    ///
    /// **NOTE**: unless the cache's `files` keys were modified `contract_file` is expected to be
    /// absolute, see [``]
    pub fn read_artifact<Artifact: DeserializeOwned>(
        &self,
        contract_file: impl AsRef<Path>,
        contract_name: impl AsRef<str>,
    ) -> Result<Artifact> {
        let contract_file = contract_file.as_ref();
        let contract_name = contract_name.as_ref();

        let artifact_path =
            self.find_artifact_path(contract_file, contract_name).ok_or_else(|| {
                SolcError::ArtifactNotFound(contract_file.to_path_buf(), contract_name.to_string())
            })?;

        utils::read_json_file(artifact_path)
    }

    /// Reads all cached artifacts from disk using the given ArtifactOutput handler
    ///
    /// # Example
    ///
    /// ```
    /// use ethers_solc::cache::SolFilesCache;
    /// use ethers_solc::Project;
    /// use ethers_solc::artifacts::contract::CompactContractBytecode;
    /// # fn t() {
    /// let project = Project::builder().build().unwrap();
    /// let cache = SolFilesCache::read_joined(&project.paths).unwrap();
    /// let artifacts = cache.read_artifacts::<CompactContractBytecode>().unwrap();
    /// # }
    /// ```
    pub fn read_artifacts<Artifact: DeserializeOwned + Send + Sync>(
        &self,
    ) -> Result<Artifacts<Artifact>> {
        use rayon::prelude::*;

        let artifacts = self
            .files
            .par_iter()
            .map(|(file, entry)| {
                let file_name = format!("{}", file.display());
                entry.read_artifact_files().map(|files| (file_name, files))
            })
            .collect::<Result<ArtifactsMap<_>>>()?;
        Ok(Artifacts(artifacts))
    }

    /// Retains only the `CacheEntry` specified by the file + version combination.
    ///
    /// In other words, only keep those cache entries with the paths (keys) that the iterator yields
    /// and only keep the versions in the cache entry that the version iterator yields.
    pub fn retain<'a, I, V>(&mut self, files: I)
    where
        I: IntoIterator<Item = (&'a Path, V)>,
        V: IntoIterator<Item = &'a Version>,
    {
        let mut files: HashMap<_, _> = files.into_iter().collect();

        self.files.retain(|file, entry| {
            if entry.artifacts.is_empty() {
                // keep entries that didn't emit any artifacts in the first place, such as a
                // solidity file that only includes error definitions
                return true
            }

            if let Some(versions) = files.remove(file.as_path()) {
                entry.retain_versions(versions);
            } else {
                return false
            }
            !entry.artifacts.is_empty()
        });
    }

    /// Inserts the provided cache entries, if there is an existing `CacheEntry` it will be updated
    /// but versions will be merged.
    pub fn extend<I>(&mut self, entries: I)
    where
        I: IntoIterator<Item = (PathBuf, CacheEntry)>,
    {
        for (file, entry) in entries.into_iter() {
            match self.files.entry(file) {
                Entry::Vacant(e) => {
                    e.insert(entry);
                }
                Entry::Occupied(mut other) => {
                    other.get_mut().merge_artifacts(entry);
                }
            }
        }
    }
}

// async variants for read and write
#[cfg(feature = "async")]
impl SolFilesCache {
    pub async fn async_read(path: impl AsRef<Path>) -> Result<Self> {
        let path = path.as_ref();
        let content =
            tokio::fs::read_to_string(path).await.map_err(|err| SolcError::io(err, path))?;
        Ok(serde_json::from_str(&content)?)
    }

    pub async fn async_write(&self, path: impl AsRef<Path>) -> Result<()> {
        let path = path.as_ref();
        let content = serde_json::to_vec_pretty(self)?;
        tokio::fs::write(path, content).await.map_err(|err| SolcError::io(err, path))
    }
}

impl Default for SolFilesCache {
    fn default() -> Self {
        SolFilesCache {
            format: ETHERS_FORMAT_VERSION.to_string(),
            files: Default::default(),
            paths: Default::default(),
        }
    }
}

impl<'a> From<&'a ProjectPathsConfig> for SolFilesCache {
    fn from(config: &'a ProjectPathsConfig) -> Self {
        let paths = config.paths_relative();
        SolFilesCache::new(Default::default(), paths)
    }
}

/// A `CacheEntry` in the cache file represents a solidity file
///
/// A solidity file can contain several contracts, for every contract a separate `Artifact` is
/// emitted. so the `CacheEntry` tracks the artifacts by name. A file can be compiled with multiple
/// `solc` versions generating version specific artifacts.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CacheEntry {
    /// the last modification time of this file
    pub last_modification_date: u64,
    /// hash to identify whether the content of the file changed
    pub content_hash: String,
    /// identifier name see [`crate::utils::source_name()`]
    pub source_name: PathBuf,
    /// what config was set when compiling this file
    pub solc_config: SolcConfig,
    /// fully resolved imports of the file
    ///
    /// all paths start relative from the project's root: `src/importedFile.sol`
    pub imports: BTreeSet<PathBuf>,
    /// The solidity version pragma
    pub version_requirement: Option<String>,
    /// all artifacts produced for this file
    ///
    /// In theory a file can be compiled by different solc versions:
    /// `A(<=0.8.10) imports C(>0.4.0)` and `B(0.8.11) imports C(>0.4.0)`
    /// file `C` would be compiled twice, with `0.8.10` and `0.8.11`, producing two different
    /// artifacts.
    ///
    /// This map tracks the artifacts by `name -> (Version -> PathBuf)`.
    /// This mimics the default artifacts directory structure
    pub artifacts: BTreeMap<String, BTreeMap<Version, PathBuf>>,
}

impl CacheEntry {
    /// Returns the last modified timestamp `Duration`
    pub fn last_modified(&self) -> Duration {
        Duration::from_millis(self.last_modification_date)
    }

    /// Returns the artifact path for the contract name
    /// ```
    /// use ethers_solc::cache::CacheEntry;
    /// # fn t(entry: CacheEntry) {
    /// entry.find_artifact_path("Greeter");
    /// # }
    /// ```
    pub fn find_artifact_path(&self, contract_name: impl AsRef<str>) -> Option<&PathBuf> {
        self.artifacts.get(contract_name.as_ref())?.iter().next().map(|(_, p)| p)
    }

    /// Reads the last modification date from the file's metadata
    pub fn read_last_modification_date(file: impl AsRef<Path>) -> Result<u64> {
        let file = file.as_ref();
        let last_modification_date = fs::metadata(file)
            .map_err(|err| SolcError::io(err, file.to_path_buf()))?
            .modified()
            .map_err(|err| SolcError::io(err, file.to_path_buf()))?
            .duration_since(UNIX_EPOCH)
            .map_err(|err| SolcError::solc(err.to_string()))?
            .as_millis() as u64;
        Ok(last_modification_date)
    }

    /// Reads all artifact files associated with the `CacheEntry`
    ///
    /// **Note:** all artifact file paths should be absolute, see [`Self::join`]
    fn read_artifact_files<Artifact: DeserializeOwned>(
        &self,
    ) -> Result<BTreeMap<String, Vec<ArtifactFile<Artifact>>>> {
        let mut artifacts = BTreeMap::new();
        for (artifact_name, versioned_files) in self.artifacts.iter() {
            let mut files = Vec::with_capacity(versioned_files.len());
            for (version, file) in versioned_files {
                let artifact: Artifact = utils::read_json_file(file)?;
                files.push(ArtifactFile { artifact, file: file.clone(), version: version.clone() });
            }
            artifacts.insert(artifact_name.clone(), files);
        }
        Ok(artifacts)
    }

    pub(crate) fn insert_artifacts<'a, I, T: 'a>(&mut self, artifacts: I)
    where
        I: IntoIterator<Item = (&'a String, Vec<&'a ArtifactFile<T>>)>,
    {
        for (name, artifacts) in artifacts.into_iter().filter(|(_, a)| !a.is_empty()) {
            let entries: BTreeMap<_, _> = artifacts
                .into_iter()
                .map(|artifact| (artifact.version.clone(), artifact.file.clone()))
                .collect();
            self.artifacts.insert(name.clone(), entries);
        }
    }

    /// Merges another `CacheEntries` artifacts into the existing set
    fn merge_artifacts(&mut self, other: CacheEntry) {
        for (name, artifacts) in other.artifacts {
            match self.artifacts.entry(name) {
                Entry::Vacant(entry) => {
                    entry.insert(artifacts);
                }
                Entry::Occupied(mut entry) => {
                    entry.get_mut().extend(artifacts.into_iter());
                }
            }
        }
    }

    /// Retains only those artifacts that match the provided versions.
    ///
    /// Removes an artifact entry if none of its versions is included in the `versions` set.
    pub fn retain_versions<'a, I>(&mut self, versions: I)
    where
        I: IntoIterator<Item = &'a Version>,
    {
        let versions = versions.into_iter().collect::<HashSet<_>>();
        self.artifacts.retain(|_, artifacts| {
            artifacts.retain(|version, _| versions.contains(version));
            !artifacts.is_empty()
        })
    }

    /// Returns `true` if the artifacts set contains the given version
    pub fn contains_version(&self, version: &Version) -> bool {
        self.artifacts_versions().any(|(v, _)| v == version)
    }

    /// Iterator that yields all artifact files and their version
    pub fn artifacts_versions(&self) -> impl Iterator<Item = (&Version, &PathBuf)> {
        self.artifacts.values().flat_map(|artifacts| artifacts.iter())
    }

    /// Returns the artifact file for the contract and version pair
    pub fn find_artifact(&self, contract: &str, version: &Version) -> Option<&PathBuf> {
        self.artifacts.get(contract).and_then(|files| files.get(version))
    }

    /// Iterator that yields all artifact files and their version
    pub fn artifacts_for_version<'a>(
        &'a self,
        version: &'a Version,
    ) -> impl Iterator<Item = &'a PathBuf> + 'a {
        self.artifacts_versions().filter_map(move |(ver, file)| (ver == version).then_some(file))
    }

    /// Iterator that yields all artifact files
    pub fn artifacts(&self) -> impl Iterator<Item = &PathBuf> {
        self.artifacts.values().flat_map(|artifacts| artifacts.values())
    }

    /// Mutable iterator over all artifact files
    pub fn artifacts_mut(&mut self) -> impl Iterator<Item = &mut PathBuf> {
        self.artifacts.values_mut().flat_map(|artifacts| artifacts.values_mut())
    }

    /// Checks if all artifact files exist
    pub fn all_artifacts_exist(&self) -> bool {
        self.artifacts().all(|p| p.exists())
    }

    /// Sets the artifact's paths to `base` adjoined to the artifact's `path`.
    pub fn join_artifacts_files(&mut self, base: impl AsRef<Path>) {
        let base = base.as_ref();
        self.artifacts_mut().for_each(|p| *p = base.join(&*p))
    }

    /// Removes `base` from the artifact's path
    pub fn strip_artifact_files_prefixes(&mut self, base: impl AsRef<Path>) {
        let base = base.as_ref();
        self.artifacts_mut().for_each(|p| {
            if let Ok(rem) = p.strip_prefix(base) {
                *p = rem.to_path_buf();
            }
        })
    }
}

/// A helper abstraction over the [`SolFilesCache`] used to determine what files need to compiled
/// and which `Artifacts` can be reused.
#[derive(Debug)]
pub(crate) struct ArtifactsCacheInner<'a, T: ArtifactOutput> {
    /// preexisting cache file
    pub cache: SolFilesCache,
    /// all already existing artifacts
    pub cached_artifacts: Artifacts<T::Artifact>,
    /// relationship between all the files
    pub edges: GraphEdges,
    /// the project
    pub project: &'a Project<T>,
    /// all files that were filtered because they haven't changed
    pub filtered: HashMap<PathBuf, (Source, HashSet<Version>)>,
    /// the corresponding cache entries for all sources that were deemed to be dirty
    ///
    /// `CacheEntry` are grouped by their solidity file.
    /// During preprocessing the `artifacts` field of a new `CacheEntry` is left blank, because in
    /// order to determine the artifacts of the solidity file, the file needs to be compiled first.
    /// Only after the `CompilerOutput` is received and all compiled contracts are handled, see
    /// [`crate::ArtifactOutput::on_output()`] all artifacts, their disk paths, are determined and
    /// can be populated before the updated [`crate::SolFilesCache`] is finally written to disk,
    /// see [`Cache::finish()`]
    pub dirty_source_files: HashMap<PathBuf, (CacheEntry, HashSet<Version>)>,
    /// the file hashes
    pub content_hashes: HashMap<PathBuf, String>,
}

impl<'a, T: ArtifactOutput> ArtifactsCacheInner<'a, T> {
    /// Creates a new cache entry for the file
    fn create_cache_entry(&self, file: &Path, source: &Source) -> CacheEntry {
        let imports = self
            .edges
            .imports(file)
            .into_iter()
            .map(|import| utils::source_name(import, self.project.root()).to_path_buf())
            .collect();

        let entry = CacheEntry {
            last_modification_date: CacheEntry::read_last_modification_date(file)
                .unwrap_or_default(),
            content_hash: source.content_hash(),
            source_name: utils::source_name(file, self.project.root()).into(),
            solc_config: self.project.solc_config.clone(),
            imports,
            version_requirement: self.edges.version_requirement(file).map(|v| v.to_string()),
            // artifacts remain empty until we received the compiler output
            artifacts: Default::default(),
        };

        entry
    }

    /// inserts a new cache entry for the given file
    ///
    /// If there is already an entry available for the file the given version is added to the set
    fn insert_new_cache_entry(&mut self, file: &Path, source: &Source, version: Version) {
        if let Some((_, versions)) = self.dirty_source_files.get_mut(file) {
            versions.insert(version);
        } else {
            let entry = self.create_cache_entry(file, source);
            self.dirty_source_files.insert(file.to_path_buf(), (entry, HashSet::from([version])));
        }
    }

    /// inserts the filtered source with the given version
    fn insert_filtered_source(&mut self, file: PathBuf, source: Source, version: Version) {
        match self.filtered.entry(file) {
            hash_map::Entry::Occupied(mut entry) => {
                entry.get_mut().1.insert(version);
            }
            hash_map::Entry::Vacant(entry) => {
                entry.insert((source, HashSet::from([version])));
            }
        }
    }

    /// Returns the set of [Source]s that need to be included in the [CompilerOutput] in order to
    /// recompile the project.
    ///
    /// We define _dirty_ sources as files that:
    ///   - are new
    ///   - were changed
    ///   - their imports were changed
    ///   - their artifact is missing
    ///
    /// A _dirty_ file is always included in the [CompilerInput].
    /// A _dirty_ file can also include clean files - files that do not match any of the above
    /// criteria - which solc also requires in order to compile a dirty file.
    ///
    /// Therefore, these files will also be included in the filtered output but not marked as dirty,
    /// so that their [OutputSelection] can be optimized in the [CompilerOutput] and their (empty)
    /// artifacts ignored.
    fn filter(&mut self, sources: Sources, version: &Version) -> FilteredSources {
        // all files that are not dirty themselves, but are pulled from a dirty file
        let mut imports_of_dirty = HashSet::new();

        // separates all source files that fit the criteria (dirty) from those that don't (clean)
        let (mut filtered_sources, clean_sources) = sources
            .into_iter()
            .map(|(file, source)| self.filter_source(file, source, version))
            .fold(
                (BTreeMap::default(), Vec::new()),
                |(mut dirty_sources, mut clean_sources), source| {
                    if source.dirty {
                        // mark all files that are imported by a dirty file
                        imports_of_dirty.extend(self.edges.all_imported_nodes(source.idx));
                        dirty_sources.insert(source.file, FilteredSource::Dirty(source.source));
                    } else {
                        clean_sources.push(source);
                    }

                    (dirty_sources, clean_sources)
                },
            );

        // track new cache entries for dirty files
        for (file, filtered) in filtered_sources.iter() {
            self.insert_new_cache_entry(file, filtered.source(), version.clone());
        }

        for clean_source in clean_sources {
            let FilteredSourceInfo { file, source, idx, .. } = clean_source;
            if imports_of_dirty.contains(&idx) {
                // file is pulled in by a dirty file
                filtered_sources.insert(file.clone(), FilteredSource::Clean(source.clone()));
            }
            self.insert_filtered_source(file, source, version.clone());
        }

        filtered_sources.into()
    }

    /// Returns the state of the given source file.
    fn filter_source(
        &self,
        file: PathBuf,
        source: Source,
        version: &Version,
    ) -> FilteredSourceInfo {
        let idx = self.edges.node_id(&file);
        if !self.is_dirty(&file, version) &&
            self.edges.imports(&file).iter().all(|file| !self.is_dirty(file, version))
        {
            FilteredSourceInfo { file, source, idx, dirty: false }
        } else {
            FilteredSourceInfo { file, source, idx, dirty: true }
        }
    }

    /// returns `false` if the corresponding cache entry remained unchanged otherwise `true`
    fn is_dirty(&self, file: &Path, version: &Version) -> bool {
        if let Some(hash) = self.content_hashes.get(file) {
            if let Some(entry) = self.cache.entry(file) {
                if entry.content_hash.as_bytes() != hash.as_bytes() {
                    tracing::trace!("changed content hash for source file \"{}\"", file.display());
                    return true
                }
                if self.project.solc_config != entry.solc_config {
                    tracing::trace!("changed solc config for source file \"{}\"", file.display());
                    return true
                }

                // only check artifact's existence if the file generated artifacts.
                // e.g. a solidity file consisting only of import statements (like interfaces that
                // re-export) do not create artifacts
                if !entry.artifacts.is_empty() {
                    if !entry.contains_version(version) {
                        tracing::trace!(
                            "missing linked artifacts for source file `{}` for version \"{}\"",
                            file.display(),
                            version
                        );
                        return true
                    }

                    if entry.artifacts_for_version(version).any(|artifact_path| {
                        let missing_artifact = !self.cached_artifacts.has_artifact(artifact_path);
                        if missing_artifact {
                            tracing::trace!("missing artifact \"{}\"", artifact_path.display());
                        }
                        missing_artifact
                    }) {
                        return true
                    }
                }
                // all things match, can be reused
                return false
            }
            tracing::trace!("Missing cache entry for {}", file.display());
        } else {
            tracing::trace!("Missing content hash for {}", file.display());
        }
        true
    }

    /// Adds the file's hashes to the set if not set yet
    fn fill_hashes(&mut self, sources: &Sources) {
        for (file, source) in sources {
            if let hash_map::Entry::Vacant(entry) = self.content_hashes.entry(file.clone()) {
                entry.insert(source.content_hash());
            }
        }
    }
}

/// Abstraction over configured caching which can be either non-existent or an already loaded cache
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub(crate) enum ArtifactsCache<'a, T: ArtifactOutput> {
    /// Cache nothing on disk
    Ephemeral(GraphEdges, &'a Project<T>),
    /// Handles the actual cached artifacts, detects artifacts that can be reused
    Cached(ArtifactsCacheInner<'a, T>),
}

impl<'a, T: ArtifactOutput> ArtifactsCache<'a, T> {
    pub fn new(project: &'a Project<T>, edges: GraphEdges) -> Result<Self> {
        /// Returns the [SolFilesCache] to use
        ///
        /// Returns a new empty cache if the cache does not exist or `invalidate_cache` is set.
        fn get_cache<T: ArtifactOutput>(
            project: &Project<T>,
            invalidate_cache: bool,
        ) -> SolFilesCache {
            // the currently configured paths
            let paths = project.paths.paths_relative();

            if !invalidate_cache && project.cache_path().exists() {
                if let Ok(cache) = SolFilesCache::read_joined(&project.paths) {
                    if cache.paths == paths {
                        // unchanged project paths
                        return cache
                    }
                }
            }

            // new empty cache
            SolFilesCache::new(Default::default(), paths)
        }

        let cache = if project.cached {
            // we only read the existing cache if we were able to resolve the entire graph
            // if we failed to resolve an import we invalidate the cache so don't get any false
            // positives
            let invalidate_cache = !edges.unresolved_imports().is_empty();

            // read the cache file if it already exists
            let mut cache = get_cache(project, invalidate_cache);

            cache.remove_missing_files();

            // read all artifacts
            let cached_artifacts = if project.paths.artifacts.exists() {
                tracing::trace!("reading artifacts from cache...");
                // if we failed to read the whole set of artifacts we use an empty set
                let artifacts = cache.read_artifacts::<T::Artifact>().unwrap_or_default();
                tracing::trace!("read {} artifacts from cache", artifacts.artifact_files().count());
                artifacts
            } else {
                Default::default()
            };

            let cache = ArtifactsCacheInner {
                cache,
                cached_artifacts,
                edges,
                project,
                filtered: Default::default(),
                dirty_source_files: Default::default(),
                content_hashes: Default::default(),
            };

            ArtifactsCache::Cached(cache)
        } else {
            // nothing to cache
            ArtifactsCache::Ephemeral(edges, project)
        };

        Ok(cache)
    }

    /// Returns the graph data for this project
    pub fn graph(&self) -> &GraphEdges {
        match self {
            ArtifactsCache::Ephemeral(graph, _) => graph,
            ArtifactsCache::Cached(inner) => &inner.edges,
        }
    }

    #[cfg(test)]
    #[allow(unused)]
    #[doc(hidden)]
    // only useful for debugging for debugging purposes
    pub fn as_cached(&self) -> Option<&ArtifactsCacheInner<'a, T>> {
        match self {
            ArtifactsCache::Ephemeral(_, _) => None,
            ArtifactsCache::Cached(cached) => Some(cached),
        }
    }

    pub fn output_ctx(&self) -> OutputContext {
        match self {
            ArtifactsCache::Ephemeral(_, _) => Default::default(),
            ArtifactsCache::Cached(inner) => OutputContext::new(&inner.cache),
        }
    }

    pub fn project(&self) -> &'a Project<T> {
        match self {
            ArtifactsCache::Ephemeral(_, project) => project,
            ArtifactsCache::Cached(cache) => cache.project,
        }
    }

    /// Adds the file's hashes to the set if not set yet
    pub fn fill_content_hashes(&mut self, sources: &Sources) {
        match self {
            ArtifactsCache::Ephemeral(_, _) => {}
            ArtifactsCache::Cached(cache) => cache.fill_hashes(sources),
        }
    }

    /// Filters out those sources that don't need to be compiled
    pub fn filter(&mut self, sources: Sources, version: &Version) -> FilteredSources {
        match self {
            ArtifactsCache::Ephemeral(_, _) => sources.into(),
            ArtifactsCache::Cached(cache) => cache.filter(sources, version),
        }
    }

    /// Consumes the `Cache`, rebuilds the [`SolFileCache`] by merging all artifacts that were
    /// filtered out in the previous step (`Cache::filtered`) and the artifacts that were just
    /// compiled and written to disk `written_artifacts`.
    ///
    /// Returns all the _cached_ artifacts.
    pub fn consume(
        self,
        written_artifacts: &Artifacts<T::Artifact>,
        write_to_disk: bool,
    ) -> Result<Artifacts<T::Artifact>> {
        match self {
            ArtifactsCache::Ephemeral(_, _) => Ok(Default::default()),
            ArtifactsCache::Cached(cache) => {
                let ArtifactsCacheInner {
                    mut cache,
                    mut cached_artifacts,
                    mut dirty_source_files,
                    filtered,
                    project,
                    ..
                } = cache;

                // keep only those files that were previously filtered (not dirty, reused)
                cache.retain(filtered.iter().map(|(p, (_, v))| (p.as_path(), v)));

                // add the written artifacts to the cache entries, this way we can keep a mapping
                // from solidity file to its artifacts
                // this step is necessary because the concrete artifacts are only known after solc
                // was invoked and received as output, before that we merely know the file and
                // the versions, so we add the artifacts on a file by file basis
                for (file, written_artifacts) in written_artifacts.as_ref() {
                    let file_path = Path::new(&file);
                    if let Some((cache_entry, versions)) = dirty_source_files.get_mut(file_path) {
                        cache_entry.insert_artifacts(written_artifacts.iter().map(
                            |(name, artifacts)| {
                                let artifacts = artifacts
                                    .iter()
                                    .filter(|artifact| versions.contains(&artifact.version))
                                    .collect::<Vec<_>>();
                                (name, artifacts)
                            },
                        ));
                    }

                    // cached artifacts that were overwritten also need to be removed from the
                    // `cached_artifacts` set
                    if let Some((f, mut cached)) = cached_artifacts.0.remove_entry(file) {
                        tracing::trace!("checking {} for obsolete cached artifact entries", file);
                        cached.retain(|name, cached_artifacts| {
                            if let Some(written_files) = written_artifacts.get(name) {
                                // written artifact clashes with a cached artifact, so we need to decide whether to keep or to remove the cached
                                cached_artifacts.retain(|f| {
                                    // we only keep those artifacts that don't conflict with written artifacts and which version was a compiler target
                                    let retain = written_files
                                        .iter()
                                        .all(|other| other.version != f.version) && filtered.get(
                                        &PathBuf::from(file)).map(|(_, versions)| {
                                            versions.contains(&f.version)
                                        }).unwrap_or_default();
                                    if !retain {
                                        tracing::trace!(
                                            "purging obsolete cached artifact {:?} for contract {} and version {}",
                                            f.file,
                                            name,
                                            f.version
                                        );
                                    }
                                    retain
                                });
                                return !cached_artifacts.is_empty()
                            }
                            false
                        });

                        if !cached.is_empty() {
                            cached_artifacts.0.insert(f, cached);
                        }
                    }
                }

                // add the new cache entries to the cache file
                cache
                    .extend(dirty_source_files.into_iter().map(|(file, (entry, _))| (file, entry)));

                // write to disk
                if write_to_disk {
                    // make all `CacheEntry` paths relative to the project root and all artifact
                    // paths relative to the artifact's directory
                    cache
                        .strip_entries_prefix(project.root())
                        .strip_artifact_files_prefixes(project.artifacts_path());
                    cache.write(project.cache_path())?;
                }

                Ok(cached_artifacts)
            }
        }
    }
}
