use std::{collections::BTreeMap, fmt, path::PathBuf, sync::Arc};

use itertools::chain;
use miette::Diagnostic;
use pixi_build_discovery::EnabledProtocols;
use pixi_glob::GlobSet;
use pixi_record::{CanonicalSourceLocation, PinnedSourceSpec, VariantValue};
use pixi_spec::ResolvedExcludeNewer;
use rattler_conda_types::{ChannelConfig, ChannelUrl};
use tokio::sync::Mutex;
use tracing::instrument;

use crate::{
    BuildEnvironment, CommandDispatcher, CommandDispatcherError, CommandDispatcherErrorResultExt,
    PackageIdentifier, SourceCheckoutError,
    build::{BuildCacheEntry, BuildCacheError, BuildInput, CachedBuild, PinnedSourceCodeLocation},
    input_hash::{ConfigurationHash, ProjectModelHash},
};

/// A query to retrieve information from the source build cache. This is
/// memoized to allow querying information from the cache while it is also
/// overwritten at the same time by a build.
///
/// The main use case for this query is to be able to *check* if a given source
/// build _was_ out of date without actually having to build the referenced
/// package.
///
/// There are two ways by which a package is considered outdated.
/// 1. A source file changed.
/// 2. A build dependency changed.
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct SourceBuildCacheStatusSpec {
    /// Describes the package to query in the source build cache.
    pub package: PackageIdentifier,

    /// Describes the source location of the package to query.
    pub source: PinnedSourceCodeLocation,

    /// The channels to use when building source packages.
    pub channels: Vec<ChannelUrl>,

    /// Exclude packages newer than the configured cutoffs when solving build environments.
    pub exclude_newer: Option<ResolvedExcludeNewer>,

    /// The build environment used to build the package.
    pub build_environment: BuildEnvironment,

    /// The channel configuration to use when building the package.
    pub channel_config: ChannelConfig,

    /// The protocols that are enabled when discovering the build backend.
    pub enabled_protocols: EnabledProtocols,

    /// The specific variant values for this build. Different variants result
    /// in different cache keys to ensure they are cached separately.
    pub variants: Option<BTreeMap<String, VariantValue>>,
}

#[derive(Debug)]
pub enum CachedBuildStatus {
    /// The build was found in the cache but is stale.
    Stale(CachedBuild),

    /// The build was found in the cache from previous session and is up to date.
    UpToDate(CachedBuild),

    /// The build was build during the running session.
    New(CachedBuild),

    /// The build was not found in the cache.
    Missing,
}

impl fmt::Display for CachedBuildStatus {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            CachedBuildStatus::Stale(build) => fmt_cached_build_status("stale", build, f),
            CachedBuildStatus::UpToDate(build) => fmt_cached_build_status("up-to-date", build, f),
            CachedBuildStatus::New(build) => fmt_cached_build_status("new", build, f),
            CachedBuildStatus::Missing => f.write_str("missing"),
        }
    }
}

fn fmt_cached_build_status(
    state: &str,
    build: &CachedBuild,
    f: &mut fmt::Formatter<'_>,
) -> fmt::Result {
    write!(f, "{state} {}", build.record.package_record)?;

    if let Some(channel) = &build.record.channel
        && !channel.is_empty()
    {
        write!(f, " @ {channel}")?;
        let subdir = build.record.package_record.subdir.as_str();
        if !subdir.is_empty() {
            write!(f, "/{subdir}")?;
        }
        return Ok(());
    }

    let subdir = build.record.package_record.subdir.as_str();
    if !subdir.is_empty() {
        write!(f, " @ {subdir}")?;
    }

    Ok(())
}

pub struct SourceBuildCacheEntry {
    /// The information stored in the build cache. Or `None` if the build did
    /// not exist in the cache.
    pub cached_build: Mutex<CachedBuildStatus>,

    /// A reference to the build entry in the cache. Not that as long as this
    /// entry exists a lock is retained on the cache entry.
    pub entry: Mutex<BuildCacheEntry>,

    /// The path where the package will be stored.
    pub cache_dir: PathBuf,
}

/// A key that uniquely identifies a request. This is used to allow in memory
/// caching of the `SourceBuildCacheStatusSpec`.
///
/// If two `SourceBuildCacheStatusSpec` have the same key, it means their
/// entries in the on disk cache would also be the same.
#[derive(Clone, Hash, Eq, PartialEq)]
pub struct SourceBuildCacheKey {
    manifest_source: CanonicalSourceLocation,
    input: BuildInput,
}

impl SourceBuildCacheStatusSpec {
    pub fn key(&self) -> SourceBuildCacheKey {
        SourceBuildCacheKey {
            manifest_source: self.source.manifest_source().into(),
            input: self.build_input(),
        }
    }

    fn build_input(&self) -> BuildInput {
        BuildInput {
            build_source: self.source.source_code().into(),
            channel_urls: self.channels.clone(),
            exclude_newer: self.exclude_newer.clone(),
            name: self.package.name.as_source().to_string(),
            version: self.package.version.to_string(),
            build: self.package.build.to_string(),
            subdir: self.package.subdir.clone(),
            host_platform: self.build_environment.host_platform,
            host_virtual_packages: self
                .build_environment
                .host_virtual_packages
                .iter()
                .cloned()
                .collect(),
            build_virtual_packages: self
                .build_environment
                .build_virtual_packages
                .iter()
                .cloned()
                .collect(),
            variants: self.variants.clone(),
        }
    }

    /// Creates a new query for the source build cache.
    #[instrument(skip_all, fields(package = %self.package, source = %self.source))]
    pub async fn query(
        self,
        command_dispatcher: CommandDispatcher,
    ) -> Result<SourceBuildCacheEntry, CommandDispatcherError<SourceBuildCacheStatusError>> {
        // Query the build cache directly.
        let (cached_build, build_cache_entry) = command_dispatcher
            .build_cache()
            .entry(
                &CanonicalSourceLocation::from(self.source.manifest_source()),
                &self.build_input(),
            )
            .await
            .map_err(SourceBuildCacheStatusError::BuildCache)
            .map_err(CommandDispatcherError::Failed)?;

        // Check the staleness of the cached entry
        let cached_build = match cached_build {
            Some(cached_build) => {
                self.determine_cache_status(&command_dispatcher, cached_build)
                    .await?
            }
            None => CachedBuildStatus::Missing,
        };

        tracing::debug!(
            "status of cached build for package '{}' is '{}'",
            self.package,
            &cached_build
        );

        Ok(SourceBuildCacheEntry {
            cached_build: Mutex::new(cached_build),
            cache_dir: build_cache_entry
                .cache_dir()
                .to_path_buf()
                .into_std_path_buf(),
            entry: Mutex::new(build_cache_entry),
        })
    }

    /// Given a cached build, verify that it is still valid for the given source
    /// record.
    async fn determine_cache_status(
        &self,
        command_dispatcher: &CommandDispatcher,
        cached_build: CachedBuild,
    ) -> Result<CachedBuildStatus, CommandDispatcherError<SourceBuildCacheStatusError>> {
        let source = &self.source;

        // Immutable source records are always considered valid.
        if source.source_code().is_immutable() {
            return Ok(CachedBuildStatus::UpToDate(cached_build));
        }

        // Check if the project configuration has changed.
        let cached_build = match self
            .check_package_configuration_changed(
                command_dispatcher,
                cached_build,
                source.manifest_source(),
            )
            .await?
        {
            CachedBuildStatus::UpToDate(cached_build) | CachedBuildStatus::New(cached_build) => {
                cached_build
            }
            CachedBuildStatus::Stale(cached_build) => {
                return Ok(CachedBuildStatus::Stale(cached_build));
            }
            CachedBuildStatus::Missing => return Ok(CachedBuildStatus::Missing),
        };

        // Determine if the package is out of date by checking the source
        let cached_build = match self
            .check_source_out_of_date(command_dispatcher, cached_build)
            .await?
        {
            CachedBuildStatus::UpToDate(cached_build) | CachedBuildStatus::New(cached_build) => {
                cached_build
            }
            CachedBuildStatus::Stale(cached_build) => {
                return Ok(CachedBuildStatus::Stale(cached_build));
            }
            CachedBuildStatus::Missing => return Ok(CachedBuildStatus::Missing),
        };

        // Otherwise, check if perhaps any of the build dependencies are out of date
        // which would cause a rebuild.
        self.check_build_dependencies_out_of_date(command_dispatcher, cached_build)
            .await
    }

    /// Checks if any of the build dependencies are out of date.
    ///
    /// A build dependency is considered out of date if:
    /// * The dependency itself is stale.
    /// * The hash of the package that was used during the build does not match
    ///   the current hash in the cache.
    async fn check_build_dependencies_out_of_date(
        &self,
        command_dispatcher: &CommandDispatcher,
        cached_build: CachedBuild,
    ) -> Result<CachedBuildStatus, CommandDispatcherError<SourceBuildCacheStatusError>> {
        let Some(source_info) = &cached_build.source else {
            return Ok(CachedBuildStatus::UpToDate(cached_build));
        };

        // Check if any of the transitive source dependencies have changed.
        for dep in chain!(&source_info.host.packages, &source_info.build.packages) {
            let Some(source) = &dep.source else {
                continue;
            };

            let identifier = PackageIdentifier::from(&dep.repodata_record.package_record);

            // Check the build cache to see if the source of that package is still fresh.
            match command_dispatcher
                .source_build_cache_status(SourceBuildCacheStatusSpec {
                    package: identifier.clone(),
                    source: source.clone(),
                    channels: self.channels.clone(),
                    exclude_newer: self.exclude_newer.clone(),
                    build_environment: self.build_environment.clone(),
                    channel_config: self.channel_config.clone(),
                    enabled_protocols: self.enabled_protocols.clone(),
                    variants: self.variants.clone(),
                })
                .await
                .try_into_failed()?
            {
                Err(SourceBuildCacheStatusError::Cycle) => {
                    tracing::debug!(
                        "a cycle was detected in the build/host dependencies of the package",
                    );
                    return Ok(CachedBuildStatus::Stale(cached_build));
                }
                Err(err) => {
                    return Err(CommandDispatcherError::Failed(err));
                }
                Ok(entry) => {
                    match &*entry.cached_build.lock().await {
                        CachedBuildStatus::Missing | CachedBuildStatus::Stale(_) => {
                            tracing::debug!(
                                "package is stale because its build dependency '{identifier}' is missing or stale",
                            );
                            return Ok(CachedBuildStatus::Stale(cached_build));
                        }
                        CachedBuildStatus::UpToDate(dependency_cached_build)
                        | CachedBuildStatus::New(dependency_cached_build) => {
                            // Is this version of the package also what we expect?
                            //
                            // Maybe the package that we previously used was actually updated
                            // without also updating this package, or the build of this package
                            // failed previously.
                            if dependency_cached_build.record.package_record.sha256
                                != dep.repodata_record.package_record.sha256
                            {
                                tracing::debug!(
                                    "package is stale because its build dependency '{identifier}' has changed",
                                );
                                return Ok(CachedBuildStatus::Stale(cached_build));
                            }
                        }
                    }
                }
            }
        }

        Ok(CachedBuildStatus::UpToDate(cached_build))
    }

    /// Checks if the package configuration has changed by computing a hash and
    /// comparing that against the stored hash.
    ///
    /// TODO: We should optimize this because currently we have to checkout the
    /// source, discover the backend, and compute hashes. We can probably
    /// already early out if some of this information is cached more granularly.
    /// E.g. if the pixi.toml file didnt change (compare using timestamp) then
    /// we can probably skip a bunch of these things.
    async fn check_package_configuration_changed(
        &self,
        command_dispatcher: &CommandDispatcher,
        cached_build: CachedBuild,
        manifest_source: &PinnedSourceSpec,
    ) -> Result<CachedBuildStatus, CommandDispatcherError<SourceBuildCacheStatusError>> {
        let Some(source_info) = &cached_build.source else {
            return Ok(CachedBuildStatus::UpToDate(cached_build));
        };

        // Checkout the source for the package.
        let source_checkout = command_dispatcher
            .checkout_pinned_source(manifest_source.clone())
            .await
            .map_err_with(SourceBuildCacheStatusError::SourceCheckout)?;

        // Determine the backend parameters for the package.
        let backend = command_dispatcher
            .discover_backend(
                source_checkout.path.as_std_path(),
                self.channel_config.clone(),
                self.enabled_protocols.clone(),
            )
            .await
            .map_err_with(|e| SourceBuildCacheStatusError::Discovery(Arc::new(e)))?;

        // Compute hashes of the package configuration.
        let project_model_hash = backend
            .init_params
            .project_model
            .as_ref()
            .map(ProjectModelHash::from);
        let configuration_hash = ConfigurationHash::compute(
            backend.init_params.configuration.as_ref(),
            backend.init_params.target_configuration.as_ref(),
        );

        // Compare the project model hash
        if source_info.project_model_hash != project_model_hash {
            tracing::debug!("package is stale because the project model hash has changed");
            return Ok(CachedBuildStatus::Stale(cached_build));
        }

        // Compare the configuration hash
        if source_info.configuration_hash != configuration_hash {
            tracing::debug!("package is stale because the configuration hash has changed");
            return Ok(CachedBuildStatus::Stale(cached_build));
        }

        Ok(CachedBuildStatus::UpToDate(cached_build))
    }

    /// Returns the status of a cached build by looking at the input files of
    /// the build as returned by the build backend.
    async fn check_source_out_of_date(
        &self,
        command_dispatcher: &CommandDispatcher,
        cached_build: CachedBuild,
    ) -> Result<CachedBuildStatus, CommandDispatcherError<SourceBuildCacheStatusError>> {
        // If there are no source globs, we always consider the cached package
        // up-to-date.
        let Some(source_info) = cached_build
            .source
            .as_ref()
            .filter(|p| !p.input_globs.is_empty())
        else {
            return Ok(CachedBuildStatus::UpToDate(cached_build));
        };

        // Checkout the source for the package.
        let source_build_checkout = command_dispatcher
            .checkout_pinned_source(self.source.source_code().clone())
            .await
            .map_err_with(SourceBuildCacheStatusError::SourceCheckout)?;

        let source_dir = source_build_checkout.path.as_dir_or_file_parent();
        let timestamp = cached_build.record.package_record.timestamp;

        // Check the files that were explicitly recorded at cache time.
        // This detects file modifications and deletions.
        for source_file_path in source_info
            .input_files
            .iter()
            .map(|path| source_dir.join(path))
        {
            match source_file_path.metadata().and_then(|m| m.modified()) {
                Ok(modified_date) => {
                    // Check if the file was modified after the package was built.
                    let modified_date = chrono::DateTime::<chrono::Utc>::from(modified_date);
                    let is_stale = timestamp.map(|t| t < modified_date).unwrap_or(true);
                    if is_stale {
                        tracing::debug!(
                            "package is stale, the file '{}' has been modified",
                            source_file_path.display()
                        );
                        return Ok(CachedBuildStatus::Stale(cached_build));
                    }
                }
                Err(err) => {
                    // File was deleted or is no longer accessible.
                    tracing::debug!(
                        "package is stale, requested metadata for '{}' failed with: {}",
                        source_file_path.display(),
                        err
                    );
                    return Ok(CachedBuildStatus::Stale(cached_build));
                }
            };
        }

        // Check if any new files match the globs that weren't recorded at cache time.
        // This detects file additions.
        let glob_set = GlobSet::create(source_info.input_globs.iter().map(String::as_str));
        let matching_files = glob_set
            .collect_matching(source_dir.as_std_path())
            .map_err(SourceBuildCacheStatusError::from)
            .map_err(CommandDispatcherError::Failed)?;

        for matching_file in matching_files {
            let path = matching_file.into_path();
            let relative_path = path
                .strip_prefix(source_dir.as_std_path())
                .ok()
                .map(|p| p.to_path_buf())
                .unwrap_or_else(|| path.clone());

            if !source_info.input_files.contains(&relative_path) {
                tracing::debug!(
                    "package is stale, a new matching file at '{}' has been detected",
                    path.display()
                );
                return Ok(CachedBuildStatus::Stale(cached_build));
            }
        }

        Ok(CachedBuildStatus::UpToDate(cached_build))
    }
}

#[derive(Debug, Clone, thiserror::Error, Diagnostic)]
pub enum SourceBuildCacheStatusError {
    #[error(transparent)]
    BuildCache(BuildCacheError),

    #[error(transparent)]
    #[diagnostic(transparent)]
    SourceCheckout(SourceCheckoutError),

    #[error(transparent)]
    #[diagnostic(transparent)]
    Discovery(Arc<pixi_build_discovery::DiscoveryError>),

    #[error(transparent)]
    GlobSet(Arc<pixi_glob::GlobSetError>),

    #[error("a cycle was detected in the build/host dependencies of the package")]
    Cycle,
}

impl From<pixi_glob::GlobSetError> for SourceBuildCacheStatusError {
    fn from(err: pixi_glob::GlobSetError) -> Self {
        Self::GlobSet(Arc::new(err))
    }
}
