use std::{
    collections::{BTreeMap, HashMap, HashSet},
    str::FromStr,
};

use either::Either;
use globwalk::{ValidatedGlob, WalkType};
use itertools::Itertools;
use thiserror::Error;
use tracing::debug;
use turbopath::{AbsoluteSystemPath, AbsoluteSystemPathBuf, RelativeUnixPathBuf};
use turborepo_env::{DetailedMap, EnvironmentVariableMap, get_global_hashable_env_vars};
use turborepo_hash::{GlobalHashable, TurboHash};
use turborepo_lockfiles::Lockfile;
use turborepo_repository::{
    package_graph::PackageInfo,
    package_manager::{self, PackageManager},
};
use turborepo_run_summary::{GlobalEnvVarSummary, GlobalHashSummary};
use turborepo_scm::SCM;
use turborepo_types::{EnvMode, GlobalHashInputs as GlobalHashInputsTrait};

#[allow(dead_code)]
static DEFAULT_ENV_VARS: [&str; 1] = ["VERCEL_ANALYTICS_ID"];

pub const GLOBAL_CACHE_KEY: &str = "I can’t see ya, but I know you’re here";

#[derive(Debug, Error)]
pub enum Error {
    #[error(transparent)]
    Env(#[from] turborepo_env::Error),
    #[error(transparent)]
    Globwalk(#[from] globwalk::WalkError),
    #[error("Invalid glob for globwalking: {0}")]
    Glob(#[from] globwalk::GlobError),
    #[error(transparent)]
    Scm(#[from] turborepo_scm::Error),
    #[error(transparent)]
    PackageManager(#[from] turborepo_repository::package_manager::Error),
    #[error(transparent)]
    Path(#[from] turbopath::PathError),
}

#[derive(Debug)]
pub struct GlobalHashableInputs<'a> {
    pub global_cache_key: &'static str,
    pub global_file_hash_map: HashMap<RelativeUnixPathBuf, String>,
    // This is `None` in single package mode
    pub root_external_dependencies_hash: Option<&'a str>,
    pub root_internal_dependencies_hash: Option<&'a str>,
    pub engines: Option<HashMap<&'a str, &'a str>>,
    pub env: &'a [String],
    // Only Option to allow #[derive(Default)]
    pub resolved_env_vars: Option<DetailedMap>,
    pub pass_through_env: Option<&'a [String]>,
    pub env_mode: EnvMode,
    pub framework_inference: bool,
    pub env_at_execution_start: &'a EnvironmentVariableMap,
    /// Whether `futureFlags.globalConfiguration` is enabled. Included in the
    /// hash so that toggling the flag deterministically invalidates caches.
    pub global_configuration: bool,
}

#[allow(clippy::too_many_arguments, clippy::result_large_err)]
pub fn get_global_hash_inputs<'a, L: ?Sized + Lockfile>(
    root_external_dependencies_hash: Option<&'a str>,
    root_internal_dependencies_hash: Option<&'a str>,
    root_package: &'a PackageInfo,
    root_path: &AbsoluteSystemPath,
    package_manager: &PackageManager,
    lockfile: Option<&L>,
    global_file_dependencies: &'a [String],
    env_at_execution_start: &'a EnvironmentVariableMap,
    global_env: &'a [String],
    global_pass_through_env: Option<&'a [String]>,
    env_mode: EnvMode,
    framework_inference: bool,
    hasher: &SCM,
    global_configuration: bool,
) -> Result<GlobalHashableInputs<'a>, Error> {
    let GlobalFileHashInputs {
        global_file_hash_map,
        global_hashable_env_vars,
        engines,
    } = collect_global_file_hash_inputs(
        root_package,
        root_path,
        package_manager,
        lockfile,
        global_file_dependencies,
        env_at_execution_start,
        global_env,
        hasher,
    )?;

    debug!(
        "external deps hash: {}",
        root_external_dependencies_hash.unwrap_or("no hash (single package)")
    );

    Ok(GlobalHashableInputs {
        global_cache_key: GLOBAL_CACHE_KEY,
        global_file_hash_map,
        root_external_dependencies_hash,
        root_internal_dependencies_hash,
        engines,
        env: global_env,
        resolved_env_vars: Some(global_hashable_env_vars),
        pass_through_env: global_pass_through_env,
        env_mode,
        framework_inference,
        env_at_execution_start,
        global_configuration,
    })
}

/// Intermediate result from `collect_global_file_hash_inputs`. Contains the
/// expensive-to-compute parts of the global hash that are independent of the
/// root external/internal dependency hashes. This allows callers to run this
/// work concurrently with external/internal deps hash computation.
pub struct GlobalFileHashInputs<'a> {
    pub global_file_hash_map: HashMap<RelativeUnixPathBuf, String>,
    pub global_hashable_env_vars: DetailedMap,
    pub engines: Option<HashMap<&'a str, &'a str>>,
}

/// Collects global file hash inputs (globwalk, file hashing, env vars).
/// This is the expensive I/O-bound portion of global hash computation and
/// can be run concurrently with package file hashing and internal deps
/// hashing since it has no dependencies on those results.
#[allow(clippy::too_many_arguments, clippy::result_large_err)]
pub fn collect_global_file_hash_inputs<'a, L: ?Sized + Lockfile>(
    root_package: &'a PackageInfo,
    root_path: &AbsoluteSystemPath,
    package_manager: &PackageManager,
    lockfile: Option<&L>,
    global_file_dependencies: &'a [String],
    env_at_execution_start: &'a EnvironmentVariableMap,
    global_env: &'a [String],
    hasher: &SCM,
) -> Result<GlobalFileHashInputs<'a>, Error> {
    let engines = root_package.package_json.engines();

    let global_hashable_env_vars =
        get_global_hashable_env_vars(env_at_execution_start, global_env)?;

    debug!(
        "global hash env vars {:?}",
        global_hashable_env_vars.all.names()
    );

    let mut global_deps =
        collect_global_deps(package_manager, root_path, global_file_dependencies)?;

    if lockfile.is_none() {
        global_deps.insert(root_path.join_component("package.json"));
        let lockfile_path = package_manager.lockfile_path(root_path);
        if lockfile_path.exists() {
            global_deps.insert(lockfile_path);
        }
    }

    // .gitattributes drives CRLF→LF normalization which affects file hashes.
    // Including it in the global hash ensures cache invalidation when
    // normalization rules change.
    let gitattributes_path = root_path.join_component(".gitattributes");
    if gitattributes_path.exists() {
        global_deps.insert(gitattributes_path);
    }

    let global_deps_paths = global_deps
        .iter()
        .map(|p| root_path.anchor(p).map_err(Error::from))
        .collect::<Result<Vec<_>, _>>()?;

    let global_file_hash_map = hasher
        .get_hashes_for_files(root_path, &global_deps_paths, false)?
        .into_iter()
        .map(|(k, v)| (k, String::from(v)))
        .collect();

    Ok(GlobalFileHashInputs {
        global_file_hash_map,
        global_hashable_env_vars,
        engines,
    })
}

#[allow(clippy::result_large_err)]
fn collect_global_deps(
    package_manager: &PackageManager,
    root_path: &AbsoluteSystemPath,
    global_file_dependencies: &[String],
) -> Result<HashSet<AbsoluteSystemPathBuf>, Error> {
    if global_file_dependencies.is_empty() {
        return Ok(HashSet::new());
    }
    let workspace_exclusions = match package_manager.get_workspace_globs(root_path) {
        Ok(globs) => globs.raw_exclusions,
        // If we hit a missing workspaces error, we could be in single package mode
        // so we should just use the default globs
        Err(package_manager::Error::Workspace(_)) => {
            package_manager.get_default_exclusions().collect()
        }
        Err(err) => {
            debug!("no workspace globs found");
            return Err(err.into());
        }
    };
    let (raw_inclusions, raw_exclusions): (Vec<_>, Vec<_>) = global_file_dependencies
        .iter()
        .partition_map(|glob| match glob.strip_prefix('!') {
            None => Either::Left(glob.as_str()),
            Some(exclusion) => Either::Right(exclusion),
        });
    let exclusions = workspace_exclusions
        .iter()
        .map(|s| s.as_str())
        .chain(raw_exclusions.iter().copied())
        .map(ValidatedGlob::from_str)
        .collect::<Result<Vec<_>, _>>()?;

    #[cfg(not(windows))]
    let inclusions = raw_inclusions
        .iter()
        .map(|i| ValidatedGlob::from_str(i))
        .collect::<Result<Vec<_>, _>>()?;
    // This is a bit of a hack to ensure that we don't crash
    // when given an absolute path on Windows. We don't support
    // absolute paths, but the ':' from the drive letter will also
    // fail to compile to a glob. We already know we aren't going to
    // get anything good, and we've already logged a warning, but we
    // can modify the glob so it compiles. This is similar to the old
    // behavior, which tacked it on to the end of the base path unmodified,
    // and then would produce no files.
    #[cfg(windows)]
    let inclusions: Vec<ValidatedGlob> = global_file_dependencies
        .iter()
        .map(|s| ValidatedGlob::from_str(&s.replace(":", "")))
        .collect::<Result<Vec<_>, _>>()?;

    Ok(globwalk::globwalk(
        root_path,
        &inclusions,
        &exclusions,
        WalkType::Files,
    )?)
}

impl<'a> GlobalHashableInputs<'a> {
    #[tracing::instrument(skip_all)]
    pub fn calculate_global_hash(&self) -> String {
        let global_hashable = GlobalHashable {
            global_cache_key: self.global_cache_key,
            global_file_hash_map: &self.global_file_hash_map,
            root_external_dependencies_hash: self.root_external_dependencies_hash,
            root_internal_dependencies_hash: self.root_internal_dependencies_hash,
            engines: self.engines.clone().unwrap_or_default(),
            env: self.env,
            resolved_env_vars: self
                .resolved_env_vars
                .as_ref()
                .map(|evm| evm.all.to_hashable())
                .unwrap_or_default(),
            pass_through_env: self.pass_through_env.unwrap_or_default(),
            env_mode: self.env_mode,
            framework_inference: self.framework_inference,
            global_configuration: self.global_configuration,
        };

        global_hashable.hash()
    }
}

// Implement TryFrom for GlobalHashSummary from GlobalHashableInputs
impl<'a> TryFrom<GlobalHashableInputs<'a>> for GlobalHashSummary<'a> {
    type Error = turborepo_run_summary::Error;

    fn try_from(global_hashable_inputs: GlobalHashableInputs<'a>) -> Result<Self, Self::Error> {
        let GlobalHashableInputs {
            global_cache_key,
            global_file_hash_map,
            root_external_dependencies_hash,
            root_internal_dependencies_hash,
            env,
            resolved_env_vars,
            pass_through_env,
            env_at_execution_start,
            engines,
            ..
        } = global_hashable_inputs;

        let pass_through = pass_through_env
            .map(
                |pass_through_env| -> Result<
                    turborepo_env::EnvironmentVariablePairs,
                    turborepo_run_summary::Error,
                > {
                    Ok(env_at_execution_start
                        .from_wildcards(pass_through_env)
                        .map_err(turborepo_run_summary::Error::Env)?
                        .to_secret_hashable())
                },
            )
            .transpose()?;

        let engines = engines.map(|engines| engines.into_iter().collect::<BTreeMap<_, _>>());

        Ok(Self {
            root_key: global_cache_key,
            files: global_file_hash_map.into_iter().collect(),
            // This can be empty in single package mode
            hash_of_external_dependencies: root_external_dependencies_hash.unwrap_or_default(),
            hash_of_internal_dependencies: root_internal_dependencies_hash.unwrap_or_default(),
            environment_variables: GlobalEnvVarSummary {
                specified: turborepo_run_summary::GlobalEnvConfiguration {
                    env,
                    pass_through_env,
                },
                configured: resolved_env_vars
                    .as_ref()
                    .map(|vars| vars.by_source.explicit.to_secret_hashable()),
                inferred: resolved_env_vars
                    .as_ref()
                    .map(|vars| vars.by_source.matching.to_secret_hashable()),
                pass_through,
            },
            engines,
        })
    }
}

// Implement GlobalHashInputs trait for use with turborepo-run-summary
impl<'a> GlobalHashInputsTrait for GlobalHashableInputs<'a> {
    fn root_key(&self) -> &str {
        self.global_cache_key
    }

    fn global_cache_key(&self) -> &str {
        self.global_cache_key
    }

    fn global_file_hash_map(&self) -> &HashMap<RelativeUnixPathBuf, String> {
        &self.global_file_hash_map
    }

    fn root_external_deps_hash(&self) -> &str {
        self.root_external_dependencies_hash.unwrap_or("")
    }

    fn env(&self) -> &[String] {
        self.env
    }

    fn resolved_env_vars(&self) -> Option<&HashMap<String, String>> {
        use std::ops::Deref;
        self.resolved_env_vars.as_ref().map(|d| d.all.deref())
    }

    fn pass_through_env(&self) -> Option<&[String]> {
        self.pass_through_env
    }

    fn env_mode(&self) -> turborepo_types::EnvMode {
        self.env_mode
    }

    fn framework_inference(&self) -> bool {
        self.framework_inference
    }

    fn dot_env(&self) -> Option<&[RelativeUnixPathBuf]> {
        // GlobalHashableInputs doesn't track dot_env directly
        None
    }
}

#[cfg(test)]
mod tests {
    use turbopath::AbsoluteSystemPathBuf;
    use turborepo_env::EnvironmentVariableMap;
    use turborepo_lockfiles::Lockfile;
    use turborepo_repository::{package_graph::PackageInfo, package_manager::PackageManager};
    use turborepo_scm::SCM;
    use turborepo_types::EnvMode;

    use super::{collect_global_deps, get_global_hash_inputs};

    #[test]
    fn test_absolute_path() {
        // We don't technically support absolute paths in global deps,
        // but we shouldn't crash. We already print out a warning.
        // Send an absolute path through and verify that we don't crash.
        let tempdir = tempfile::tempdir().unwrap();
        let root = AbsoluteSystemPathBuf::try_from(tempdir.path())
            .unwrap()
            .to_realpath()
            .unwrap();
        // Always default included, so it has to exist
        root.join_component("package.json")
            .create_with_contents("{}")
            .unwrap();

        let env_var_map = EnvironmentVariableMap::default();
        let package_info = PackageInfo::default();
        let lockfile: Option<&dyn Lockfile> = None;
        #[cfg(windows)]
        let file_deps = ["C:\\some\\path".to_string()];
        #[cfg(not(windows))]
        let file_deps = ["/some/path".to_string()];
        let result = get_global_hash_inputs(
            None,
            None,
            &package_info,
            &root,
            &PackageManager::Pnpm,
            lockfile,
            &file_deps,
            &env_var_map,
            &[],
            None,
            EnvMode::Strict,
            false,
            &SCM::new(&root),
            false,
        );
        assert!(result.is_ok());
    }

    /// get_global_hash_inputs should not yield any folders when walking since
    /// turbo does not consider changes to folders when evaluating hashes,
    /// only to files
    #[test]
    fn test_collect_only_yields_files() {
        let tmp = tempfile::tempdir().unwrap();

        // add some files
        //   - package.json
        //   - src/index.js
        //   - src/index.test.js
        //   - empty-folder/

        let root = AbsoluteSystemPathBuf::try_from(tmp.path()).unwrap();
        let src = root.join_component("src");

        root.join_component("package.json")
            .create_with_contents("{}")
            .unwrap();
        root.join_component("empty-folder")
            .create_dir_all()
            .unwrap();

        src.create_dir_all().unwrap();
        src.join_component("index.js")
            .create_with_contents("console.log('hello world');")
            .unwrap();
        src.join_component("index.test.js")
            .create_with_contents("")
            .unwrap();

        let global_file_dependencies = vec!["**".to_string()];
        let results =
            collect_global_deps(&PackageManager::Berry, &root, &global_file_dependencies).unwrap();

        // should not yield the root folder itself, src, or empty-folder
        assert_eq!(results.len(), 3, "{:?}", results);
    }
}
