pub(crate) mod conda_metadata;
mod conda_prefix;
pub mod list;
pub use conda_prefix::{CondaPrefixUpdated, CondaPrefixUpdater, CondaPrefixUpdaterBuilder};
use dialoguer::theme::ColorfulTheme;
use futures::{FutureExt, StreamExt, TryStreamExt, stream};
use miette::{Context, IntoDiagnostic};
use pixi_consts::consts;
use pixi_git::credentials::store_credentials_from_url;
pub use pixi_install_pypi::{ContinuePyPIPrefixUpdate, on_python_interpreter_change};
use pixi_manifest::FeaturesExt;
use pixi_progress::await_in_progress;
use pixi_pypi_spec::PixiPypiSource;
pub use pixi_python_status::PythonStatus;
use pixi_spec::{GitSpec, PixiSpec};
use pixi_utils::{prefix::Prefix, rlimit::try_increase_rlimit_to_sensible};
use rattler_conda_types::Platform;
use rattler_lock::{LockFile, LockedPackageRef};
use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter};
use std::{
    collections::HashMap,
    hash::{Hash, Hasher},
    io::ErrorKind,
    path::{Path, PathBuf},
};
use xxhash_rust::xxh3::Xxh3;

use crate::workspace;
use crate::{
    Workspace,
    lock_file::{LockFileDerivedData, ReinstallPackages, UpdateLockFileOptions, UpdateMode},
    workspace::{
        Environment, HasWorkspaceRef, errors::UnsupportedPlatformError,
        grouped_environment::GroupedEnvironment,
    },
};

/// Verify the location of the prefix folder is not changed so the applied
/// prefix path is still valid. Errors when there is a file system error or the
/// path does not align with the defined prefix. Returns false when the file is
/// not present.
pub async fn verify_prefix_location_unchanged(environment_dir: &Path) -> miette::Result<()> {
    let prefix_file = environment_dir
        .join(consts::CONDA_META_DIR)
        .join(consts::PREFIX_FILE_NAME);

    tracing::debug!(
        "verifying prefix location is unchanged, with prefix file: {}",
        prefix_file.display()
    );

    match fs_err::read_to_string(prefix_file.clone()) {
        // Not found is fine as it can be new or backwards compatible.
        Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
        // Scream the error if we don't know it.
        Err(e) => {
            tracing::error!("failed to read prefix file: {}", prefix_file.display());
            Err(e).into_diagnostic()
        }
        // Check if the path in the file aligns with the current path.
        Ok(p) if prefix_file.starts_with(&p) => Ok(()),
        Ok(p) => {
            let path = Path::new(&p);
            prefix_location_changed(environment_dir, path.parent().unwrap_or(path)).await
        }
    }
}

/// Called when the prefix has moved to a new location.
///
/// Allows interactive users to delete the location and continue.
async fn prefix_location_changed(
    environment_dir: &Path,
    previous_dir: &Path,
) -> miette::Result<()> {
    let theme = ColorfulTheme {
        active_item_style: console::Style::new().for_stderr().magenta(),
        ..ColorfulTheme::default()
    };

    let user_value = dialoguer::Confirm::with_theme(&theme)
        .with_prompt(format!(
            "The environment directory seems have to moved! Environments are non-relocatable, moving them can cause issues.\n\n\t{} -> {}\n\nThis can be fixed by reinstall the environment from the lock-file in the new location.\n\nDo you want to automatically recreate the environment?",
            previous_dir.display(),
            environment_dir.display()
        ))
        .report(false)
        .default(true)
        .interact_opt()
        .map_or(None, std::convert::identity);
    if user_value == Some(true) {
        await_in_progress("removing old environment", |_| {
            tokio::fs::remove_dir_all(environment_dir)
        })
        .await
        .into_diagnostic()
        .context("failed to remove old environment directory")?;
        Ok(())
    } else {
        Err(miette::diagnostic!(
            help = "Remove the environment directory, pixi will recreate it on the next run.",
            "The environment directory has moved from `{}` to `{}`. Environments are non-relocatable, moving them can cause issues.", previous_dir.display(), environment_dir.display()
        )
            .into())
    }
}

#[derive(Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub struct EnvironmentHash(String);

impl EnvironmentHash {
    pub fn from_environment(
        run_environment: &workspace::Environment<'_>,
        input_environment_variables: &HashMap<String, Option<String>>,
        lock_file: &LockFile,
    ) -> Self {
        let mut hasher = Xxh3::new();

        // Hash the environment variables
        let mut sorted_input_environment_variables: Vec<_> =
            input_environment_variables.iter().collect();
        sorted_input_environment_variables.sort_by_key(|(key, _)| *key);
        for (key, value) in sorted_input_environment_variables {
            key.hash(&mut hasher);
            value.hash(&mut hasher);
        }

        // Hash the activation scripts
        let activation_scripts =
            run_environment.activation_scripts(Some(run_environment.best_platform()));
        for script in activation_scripts {
            script.hash(&mut hasher);
        }

        // Hash the environment variables
        let project_activation_env =
            run_environment.activation_env(Some(run_environment.best_platform()));
        let mut env_vars: Vec<_> = project_activation_env.iter().collect();
        env_vars.sort_by_key(|(key, _)| *key);

        for (key, value) in env_vars {
            key.hash(&mut hasher);
            value.hash(&mut hasher);
        }

        // Hash the packages
        let mut urls = Vec::new();
        if let Some(env) = lock_file.environment(run_environment.name().as_str())
            && let Some(packages) = env.packages(run_environment.best_platform())
        {
            for package in packages {
                urls.push(package.location().to_string())
            }
        }
        urls.sort();
        urls.hash(&mut hasher);

        EnvironmentHash(format!("{:x}", hasher.finish()))
    }
}

impl Display for EnvironmentHash {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.0)
    }
}

#[derive(Debug, Hash, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockedEnvironmentHash(String);
impl LockedEnvironmentHash {
    pub(crate) fn from_environment(
        environment: rattler_lock::Environment,
        platform: Platform,
    ) -> Self {
        let mut hasher = Xxh3::new();

        // Intentionally ignore `skipped` here: the quick-validate cache is only
        // used during runs, and should not vary based on transient install
        // filters.
        if let Some(packages) = environment.packages(platform) {
            for package in packages {
                // Always has the url or path
                package.location().to_owned().to_string().hash(&mut hasher);

                match package {
                    // A select set of fields are used to hash the package
                    LockedPackageRef::Conda(pack) => {
                        if let Some(sha) = pack.record().sha256 {
                            sha.hash(&mut hasher);
                        } else if let Some(md5) = pack.record().md5 {
                            md5.hash(&mut hasher);
                        }
                    }
                    LockedPackageRef::Pypi(pack, env) => {
                        pack.editable.hash(&mut hasher);
                        env.extras.hash(&mut hasher);
                    }
                }
            }
        }

        LockedEnvironmentHash(format!("{:x}", hasher.finish()))
    }
}

impl LockedEnvironmentHash {
    /// Create an invalid hash for revalidation purposes
    pub(crate) fn invalid() -> Self {
        LockedEnvironmentHash("invalid-hash".to_string())
    }
}

/// Information about the environment that was used to create the environment.
#[derive(Serialize, Deserialize)]
pub(crate) struct EnvironmentFile {
    /// The path to the manifest file that was used to create the environment.
    pub(crate) manifest_path: PathBuf,
    /// The name of the environment.
    pub(crate) environment_name: String,
    /// The version of the pixi that was used to create the environment.
    pub(crate) pixi_version: String,
    /// The hash of the lock file that was used to create the environment.
    pub(crate) environment_lock_file_hash: LockedEnvironmentHash,
}

/// The path to the environment file in the `conda-meta` directory of the
/// environment.
fn environment_file_path(environment_dir: &Path) -> PathBuf {
    environment_dir
        .join(consts::CONDA_META_DIR)
        .join(consts::ENVIRONMENT_FILE_NAME)
}
/// Write information about the environment to a file in the environment
/// directory. Used by the prefix updating to validate if it needs to be
/// updated.
pub(crate) fn write_environment_file(
    environment_dir: &Path,
    env_file: EnvironmentFile,
) -> miette::Result<PathBuf> {
    let path = environment_file_path(environment_dir);

    let parent = path
        .parent()
        .expect("There should already be a conda-meta folder");

    match fs_err::create_dir_all(parent).into_diagnostic() {
        Ok(_) => {
            // Using json as it's easier to machine read it.
            let contents = serde_json::to_string_pretty(&env_file).into_diagnostic()?;
            match fs_err::write(&path, contents).into_diagnostic() {
                Ok(_) => {
                    tracing::debug!("Wrote environment file to: {:?}", path);
                }
                Err(e) => tracing::debug!(
                    "Unable to write environment file to: {:?} => {:?}",
                    path,
                    e.root_cause().to_string()
                ),
            };
            Ok(path)
        }
        Err(e) => {
            tracing::debug!("Unable to create conda-meta folder to: {:?}", path);
            Err(e)
        }
    }
}

/// Reading the environment file of the environment.
/// Removing it if it's not valid.
pub(crate) fn read_environment_file(
    environment_dir: &Path,
) -> miette::Result<Option<EnvironmentFile>> {
    let path = environment_file_path(environment_dir);

    let contents = match fs_err::read_to_string(&path) {
        Ok(contents) => contents,
        Err(e) if e.kind() == ErrorKind::NotFound => {
            tracing::debug!("Environment file not yet found at: {:?}", path);
            return Ok(None);
        }
        Err(e) => {
            tracing::debug!(
                "Failed to read environment file at: {:?}, error: {}, will try to remove it.",
                path,
                e
            );
            let _ = fs_err::remove_file(&path);
            return Err(e).into_diagnostic();
        }
    };
    let env_file: EnvironmentFile = match serde_json::from_str(&contents) {
        Ok(env_file) => env_file,
        Err(e) => {
            tracing::debug!(
                "Failed to read environment file at: {:?}, error: {}, will try to remove it.",
                path,
                e
            );
            let _ = fs_err::remove_file(&path);
            return Ok(None);
        }
    };

    Ok(Some(env_file))
}

/// Runs the following checks to make sure the project is in a sane state:
///     1. It verifies that the prefix location is unchanged.
///     2. It verifies that the system requirements are met.
///     3. It verifies the absence of the `env` folder.
///     4. It verifies that the prefix contains a `.gitignore` file.
pub async fn sanity_check_workspace(project: &Workspace) -> miette::Result<()> {
    // Sanity check of prefix location
    verify_prefix_location_unchanged(project.environments_dir().as_path()).await?;

    // TODO: remove on a 1.0 release
    // Check for old `env` folder as we moved to `envs` in 0.13.0
    let old_pixi_env_dir = project.pixi_dir().join("env");
    if old_pixi_env_dir.exists() {
        tracing::warn!(
            "The `{}` folder is deprecated, please remove it as we now use the `{}` folder",
            old_pixi_env_dir.display(),
            consts::ENVIRONMENTS_DIR
        );
    }

    ensure_pixi_directory_and_gitignore(project.pixi_dir().as_path()).await?;

    Ok(())
}

/// Extract [`GitSpec`] requirements from the project dependencies.
pub fn extract_git_requirements_from_workspace(project: &Workspace) -> Vec<GitSpec> {
    let mut requirements = Vec::new();

    for env in project.environments() {
        let env_platforms = env.platforms();
        for platform in env_platforms {
            let dependencies = env.combined_dependencies(Some(platform));
            let pypi_dependencies = env.pypi_dependencies(Some(platform));
            for (_, dep_spec) in dependencies {
                for spec in dep_spec {
                    if let PixiSpec::Git(spec) = spec {
                        requirements.push(spec.clone());
                    }
                }
            }

            for (_, pypi_spec) in pypi_dependencies {
                for spec in pypi_spec {
                    if let PixiPypiSource::Git { git, .. } = &spec.source {
                        requirements.push(git.clone());
                    }
                }
            }
        }
    }

    requirements
}

/// Store credentials from [`GitSpec`] requirements.
pub fn store_credentials_from_requirements(git_requirements: Vec<GitSpec>) {
    for spec in git_requirements {
        store_credentials_from_url(&spec.git);
    }
}

/// Extract any credentials that are defined on the project dependencies
/// themselves. While we don't store plaintext credentials in the `pixi.lock`,
/// we do respect credentials that are defined in the `pixi.toml` or
/// `pyproject.toml`.
pub async fn store_credentials_from_project(project: &Workspace) -> miette::Result<()> {
    for env in project.environments() {
        let env_platforms = env.platforms();
        for platform in env_platforms {
            let dependencies = env.combined_dependencies(Some(platform));
            for (_, dep_spec) in dependencies {
                for spec in dep_spec {
                    if let PixiSpec::Git(spec) = spec {
                        store_credentials_from_url(&spec.git);
                    }
                }
            }
        }
    }

    Ok(())
}

/// Create a file at the given path with the given contents if it does not exist.
/// If the file already exists, it does nothing.
/// If the file cannot be created due to a read-only filesystem,
/// we'll ignore the error as it's not that important to the function of pixi.
async fn best_effort_write_file_if_missing(
    path: &Path,
    contents: &str,
    error_message: &str,
) -> miette::Result<()> {
    if !path.exists() {
        match tokio::fs::write(path, contents).await {
            Ok(_) => Ok(()),
            Err(e) if e.kind() == ErrorKind::ReadOnlyFilesystem => {
                tracing::debug!("Failed to create file at: {}, error: {}", path.display(), e);
                Ok(())
            }
            Err(e) => Err(e)
                .into_diagnostic()
                .wrap_err(format!("{error_message} {}", path.display())),
        }?;
    }
    Ok(())
}

/// Ensure that the `.pixi/` directory exists and contains a `.gitignore` file.
/// If the directory doesn't exist, create it.
/// If the `.gitignore` file doesn't exist, create it with a '*' pattern.
/// Also creates a `.condapackageignore` file to exclude the `.pixi` directory
/// from builds.
async fn ensure_pixi_directory_and_gitignore(pixi_dir: &Path) -> miette::Result<()> {
    let gitignore_path = pixi_dir.join(".gitignore");
    let condapackageignore_path = pixi_dir.join(".condapackageignore");

    // Create the `.pixi/` directory if it doesn't exist
    if !pixi_dir.exists() {
        tokio::fs::create_dir_all(&pixi_dir)
            .await
            .into_diagnostic()
            .wrap_err(format!(
                "Failed to create .pixi/ directory at {}",
                pixi_dir.display()
            ))?;
    }

    best_effort_write_file_if_missing(
        &gitignore_path,
        "*\n!config.toml\n",
        "Failed to create .gitignore file at",
    )
    .await?;

    best_effort_write_file_if_missing(
        &condapackageignore_path,
        ".pixi\n!.pixi/config.toml\n",
        "Failed to create .condapackageignore file at",
    )
    .await?;

    Ok(())
}

/// Specifies how the lock-file should be updated.
#[derive(Debug, Default, PartialEq, Eq, Copy, Clone, Deserialize, Serialize)]
pub enum LockFileUsage {
    /// Update the lock-file if it is out of date.
    #[default]
    Update,
    /// Don't update the lock-file, but do check if it is out of date
    Locked,
    /// Don't update the lock-file and don't check if it is out of date
    Frozen,
    /// Don't update the lock-file, but don't check if it is out of date
    DryRun,
}

impl LockFileUsage {
    /// Returns true if the process should error when the lock-file
    pub(crate) fn allow_updates(self) -> bool {
        !matches!(self, LockFileUsage::Locked)
    }

    /// Returns true if the lock-file should be checked if it is out of date.
    pub(crate) fn should_check_if_out_of_date(self) -> bool {
        match self {
            LockFileUsage::Update | LockFileUsage::Locked | LockFileUsage::DryRun => true,
            LockFileUsage::Frozen => false,
        }
    }
}

/// Options to select a subset of packages to install or skip.
#[derive(Debug, Default, Clone)]
pub struct InstallFilter {
    /// Packages to skip directly but still traverse through their dependencies
    pub skip_direct: Vec<String>,
    /// Packages to skip together with their dependencies (hard stop)
    pub skip_with_deps: Vec<String>,
    /// Target one or more packages (and their deps) to install; empty means no targeting
    pub target_packages: Vec<String>,
}

impl InstallFilter {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn skip_direct(mut self, packages: impl Into<Vec<String>>) -> Self {
        self.skip_direct = packages.into();
        self
    }

    pub fn skip_with_deps(mut self, packages: impl Into<Vec<String>>) -> Self {
        self.skip_with_deps = packages.into();
        self
    }

    pub fn target_packages(mut self, packages: impl Into<Vec<String>>) -> Self {
        self.target_packages = packages.into();
        self
    }

    /// Is the filter currently active
    pub fn filter_active(&self) -> bool {
        !self.skip_direct.is_empty()
            || !self.skip_with_deps.is_empty()
            || !self.target_packages.is_empty()
    }
}

/// Update the prefix if it doesn't exist or if it is not up-to-date.
///
/// To updated multiple prefixes at once, use [`get_update_lock_file_and_prefixes`].
pub async fn get_update_lock_file_and_prefix<'env>(
    environment: &Environment<'env>,
    update_mode: UpdateMode,
    update_lock_file_options: UpdateLockFileOptions,
    reinstall_packages: ReinstallPackages,
    filter: &InstallFilter,
) -> miette::Result<(LockFileDerivedData<'env>, Prefix)> {
    let (lock_file, prefixes) = get_update_lock_file_and_prefixes(
        std::slice::from_ref(environment),
        update_mode,
        update_lock_file_options,
        reinstall_packages,
        filter,
    )
    .await?;
    Ok((
        lock_file,
        prefixes
            .into_iter()
            .next()
            .expect("must be at least one prefix"),
    ))
}

/// Update all the specified prefixes if it doesn't exist or if it is not
/// up-to-date.
pub async fn get_update_lock_file_and_prefixes<'env>(
    environments: &[Environment<'env>],
    update_mode: UpdateMode,
    update_lock_file_options: UpdateLockFileOptions,
    reinstall_packages: ReinstallPackages,
    filter: &InstallFilter,
) -> miette::Result<(LockFileDerivedData<'env>, Vec<Prefix>)> {
    if environments.is_empty() {
        return Err(miette::miette!("No environments provided to install."));
    }

    let workspace = environments[0].workspace();

    let no_install = update_lock_file_options.no_install;
    for env in environments {
        let current_platform = env.best_platform();
        if !no_install && !env.platforms().contains(&current_platform) {
            return Err(UnsupportedPlatformError {
                environments_platforms: env.platforms().into_iter().collect(),
                environment: env.name().clone(),
                platform: current_platform,
            }
            .into());
        }
        if !no_install {
            env.emit_emulation_warning();
        }
    }

    // Make sure the project is in a sane state
    sanity_check_workspace(workspace).await?;

    // Store the git credentials from the git requirements
    let requirements = extract_git_requirements_from_workspace(workspace);
    store_credentials_from_requirements(requirements);

    // Ensure that the lock-file is up-to-date
    let lock_file = workspace
        .update_lock_file(UpdateLockFileOptions {
            lock_file_usage: update_lock_file_options.lock_file_usage,
            no_install,
            max_concurrent_solves: update_lock_file_options.max_concurrent_solves,
        })
        .await?
        .0;

    // Get the prefix from the lock-file.
    let lock_file_ref = &lock_file;
    let reinstall_packages = &reinstall_packages;
    let prefixes = stream::iter(environments.iter())
        .map(move |env| {
            if no_install {
                std::future::ready(Ok(Prefix::new(env.dir()))).left_future()
            } else {
                lock_file_ref
                    .prefix(env, update_mode, reinstall_packages, filter)
                    .right_future()
            }
        })
        .buffer_unordered(environments.len())
        .try_collect()
        .await?;

    Ok((lock_file, prefixes))
}

pub type PerEnvironment<'p, T> = HashMap<Environment<'p>, T>;
pub type PerGroup<'p, T> = HashMap<GroupedEnvironment<'p>, T>;
pub type PerEnvironmentAndPlatform<'p, T> = PerEnvironment<'p, HashMap<Platform, T>>;
pub type PerGroupAndPlatform<'p, T> = PerGroup<'p, HashMap<Platform, T>>;
