use std::{
    collections::HashMap,
    fs::{self, File},
    io::Write,
    sync::{Arc, Mutex},
};

use serde::Deserialize;
use tracing::{debug, warn};
use turbopath::{AbsoluteSystemPathBuf, PathRelation};
use turborepo_cache::AsyncCache;
use turborepo_gitignore::ensure_turbo_is_gitignored;
use turborepo_scm::SCM;
use turborepo_task_executor::TaskAccessProvider;
use turborepo_turbo_json::TASK_ACCESS_CONFIG_PATH;
use turborepo_unescape::UnescapedString;

use super::ConfigCache;
use crate::turbo_json::{RawTurboJson, RawTurboJsonExt};

// Environment variable key that will be used to enable, and set the expected
// trace location
const TASK_ACCESS_ENV_KEY: &str = "TURBOREPO_TRACE_FILE";
/// File name where the task is expected to leave a trace result
const TASK_ACCESS_TRACE_NAME: &str = "trace.json";
/// File name where the task is expected to leave a trace result
const TURBO_CONFIG_FILE: &str = "turbo.json";

#[derive(Debug, thiserror::Error)]
pub enum ToFileError {
    #[error("Unable to serialize traced config: {0}")]
    Serialize(#[from] serde_json::Error),
    #[error("Unable to write traced config: {0}")]
    IO(#[from] std::io::Error),
    #[error("Unable to cache traced config: {0}")]
    Cache(#[from] turborepo_cache::CacheError),
}

#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TaskAccessTraceAccess {
    pub network: bool,
    pub file_paths: Vec<UnescapedString>,
    pub env_var_keys: Vec<UnescapedString>,
}

#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct TaskAccessTraceFile {
    pub accessed: TaskAccessTraceAccess,
    pub outputs: Vec<UnescapedString>,
}

#[derive(Deserialize, Debug)]
struct PackageJson {
    scripts: Option<std::collections::HashMap<String, String>>,
}

pub fn trace_file_path(
    repo_root: &AbsoluteSystemPathBuf,
    task_hash: &str,
) -> AbsoluteSystemPathBuf {
    repo_root.join_components(&[".turbo", task_hash, TASK_ACCESS_TRACE_NAME])
}

fn task_access_trace_enabled(repo_root: &AbsoluteSystemPathBuf) -> Result<bool, std::io::Error> {
    // TODO: use the existing config methods here
    let root_turbo_json_path = &repo_root.join_component(TURBO_CONFIG_FILE);
    if root_turbo_json_path.exists() {
        return Ok(false);
    }

    // read package.json at root
    let package_json_path = repo_root.join_components(&["package.json"]);
    let package_json_content = fs::read_to_string(package_json_path)?;
    let package: PackageJson = serde_json::from_str(&package_json_content)?;

    if let Some(scripts) = package.scripts {
        return match scripts.get("build") {
            Some(script) => Ok(script == "next build"),
            _ => Ok(false),
        };
    }

    Ok(false)
}

impl TaskAccessTraceFile {
    pub fn read(repo_root: &AbsoluteSystemPathBuf, task_hash: &str) -> Option<TaskAccessTraceFile> {
        let trace_file = trace_file_path(repo_root, task_hash);

        let Ok(f) = trace_file.open() else {
            return None;
        };

        match serde_json::from_reader(f) {
            Ok(trace) => Some(trace),
            Err(e) => {
                warn!("failed to parse trace file {trace_file}: {e}");
                None
            }
        }
    }

    pub fn can_cache(&self, repo_root: &AbsoluteSystemPathBuf) -> bool {
        // network
        if self.accessed.network {
            turborepo_log::warn(
                turborepo_log::Source::turbo(turborepo_log::Subsystem::TaskAccess),
                "skipping automatic task caching - detected network access",
            )
            .emit();
            return false;
        }

        // file system
        for unescaped_str in &self.accessed.file_paths {
            match AbsoluteSystemPathBuf::new(unescaped_str.to_string()) {
                Ok(path) => {
                    let relation = path.relation_to_path(repo_root);
                    // only paths within the repo can be automatically cached
                    if relation == PathRelation::Parent || relation == PathRelation::Divergent {
                        turborepo_log::warn(
                            turborepo_log::Source::turbo(turborepo_log::Subsystem::TaskAccess),
                            format!(
                                "skipping automatic task caching - file accessed outside of repo \
                                 root ({unescaped_str})"
                            ),
                        )
                        .emit();
                        return false;
                    }
                }
                Err(e) => {
                    debug!("failed to parse path {unescaped_str}: {e}");
                }
            }
        }

        for output in &self.outputs {
            let output = output.strip_prefix('!').unwrap_or(output.as_str());
            if is_windows_absolute_path(output) {
                turborepo_log::warn(
                    turborepo_log::Source::turbo(turborepo_log::Subsystem::TaskAccess),
                    format!(
                        "skipping automatic task caching - output generated outside of repo root \
                         ({output})"
                    ),
                )
                .emit();
                return false;
            }

            let path = AbsoluteSystemPathBuf::new(output.to_string())
                .unwrap_or_else(|_| AbsoluteSystemPathBuf::from_unknown(repo_root, output));
            let relation = path.relation_to_path(repo_root);
            if relation == PathRelation::Parent || relation == PathRelation::Divergent {
                turborepo_log::warn(
                    turborepo_log::Source::turbo(turborepo_log::Subsystem::TaskAccess),
                    format!(
                        "skipping automatic task caching - output generated outside of repo root \
                         ({output})"
                    ),
                )
                .emit();
                return false;
            }
        }

        true
    }
}

fn is_windows_absolute_path(path: &str) -> bool {
    let bytes = path.as_bytes();
    bytes.first() == Some(&b'\\')
        || matches!(bytes, [drive, b':', ..] if drive.is_ascii_alphabetic())
}

#[derive(Clone)]
pub struct TaskAccess {
    repo_root: AbsoluteSystemPathBuf,
    trace_by_task: Arc<Mutex<HashMap<String, TaskAccessTraceFile>>>,
    config_cache: Option<ConfigCache>,
    enabled: bool,
}

impl TaskAccess {
    pub fn new(repo_root: AbsoluteSystemPathBuf, cache: AsyncCache, scm: &SCM) -> Self {
        let root = repo_root.clone();
        let enabled = task_access_trace_enabled(&root).unwrap_or(false);
        let trace_by_task = Arc::new(Mutex::new(HashMap::<String, TaskAccessTraceFile>::new()));
        let mut config_cache = Option::<ConfigCache>::None;

        // we only want to setup the config cacher if task access tracing is enabled
        if enabled {
            // make sure .turbo is ignored
            match ensure_turbo_is_gitignored(&repo_root) {
                Ok(_) => debug!("Automatically added .turbo to .gitignore"),
                Err(e) => {
                    turborepo_log::error(
                        turborepo_log::Source::turbo(turborepo_log::Subsystem::TaskAccess),
                        format!(
                            "Failed to add .turbo to .gitignore. Caching will be disabled - {e}"
                        ),
                    )
                    .emit();
                }
            }

            let config_hash_result = ConfigCache::calculate_config_hash(scm, &root);
            if let Ok(c_hash) = config_hash_result {
                let c_cache = ConfigCache::new(
                    c_hash.to_string(),
                    root.clone(),
                    &TASK_ACCESS_CONFIG_PATH,
                    cache.clone(),
                );

                config_cache = Some(c_cache);
            }
        }

        Self {
            repo_root,
            trace_by_task,
            enabled,
            config_cache,
        }
    }

    pub fn is_enabled(&self) -> bool {
        self.enabled
    }

    /// Check if task access tracing is enabled without constructing the
    /// full TaskAccess (which requires a cache). Used early in the build
    /// pipeline before the HTTP client is available.
    pub fn check_enabled(repo_root: &AbsoluteSystemPathBuf) -> bool {
        task_access_trace_enabled(repo_root).unwrap_or(false)
    }

    pub async fn restore_config(&self) {
        match (self.enabled, &self.config_cache) {
            (true, Some(config_cache)) => match config_cache.restore().await {
                Ok(_) => debug!(
                    "TASK ACCESS TRACE: config restored for {}",
                    config_cache.hash()
                ),
                Err(_) => debug!(
                    "TASK ACCESS TRACE: no config found for {}",
                    config_cache.hash()
                ),
            },
            _ => {
                debug!("TASK ACCESS TRACE: unable to restore config from cache");
            }
        }
    }

    pub fn save_trace(&self, task_id: String, trace: TaskAccessTraceFile) {
        let trace_by_task = self.trace_by_task.lock();
        match trace_by_task {
            Ok(mut trace_by_task) => {
                trace_by_task.insert(task_id, trace);
            }
            Err(e) => {
                turborepo_log::error(
                    turborepo_log::Source::turbo(turborepo_log::Subsystem::TaskAccess),
                    format!("Failed to save trace result - {e}"),
                )
                .emit();
            }
        }
    }

    pub fn get_env_var(&self, task_hash: &str) -> (String, AbsoluteSystemPathBuf) {
        let trace_file_path = trace_file_path(&self.repo_root, task_hash);
        (TASK_ACCESS_ENV_KEY.to_string(), trace_file_path)
    }

    pub async fn save(&self) {
        match self.to_file().await {
            Ok(_) => (),
            Err(e) => {
                turborepo_log::error(
                    turborepo_log::Source::turbo(turborepo_log::Subsystem::TaskAccess),
                    format!("Failed to write task access trace file - {e}"),
                )
                .emit();
            }
        }
    }

    // Whether we can cache the given task, returning None if task access isn't
    // enabled or the trace can't be found
    pub fn can_cache(&self, task_hash: &str, task_id: &str) -> Option<bool> {
        if !self.is_enabled() {
            return None;
        }
        let trace = TaskAccessTraceFile::read(&self.repo_root, task_hash)?;
        if trace.can_cache(&self.repo_root) {
            self.save_trace(task_id.to_string(), trace);
            Some(true)
        } else {
            Some(false)
        }
    }

    async fn to_file(&self) -> Result<(), ToFileError> {
        // if task access tracing is not enabled, we don't need to do anything
        if !self.is_enabled() {
            return Ok(());
        }

        if let Some(config_cache) = &self.config_cache {
            let traced_config = {
                let trace_by_task = self
                    .trace_by_task
                    .lock()
                    .unwrap_or_else(|poisoned| poisoned.into_inner());
                RawTurboJson::from_task_access_trace(&trace_by_task)
            };
            if traced_config.is_some() {
                // convert the traced_config to json and write the file to disk
                let traced_config_json = serde_json::to_string_pretty(&traced_config)?;
                let file_path = self.repo_root.join_components(&TASK_ACCESS_CONFIG_PATH);
                let mut file = File::create(file_path)?;
                write!(file, "{traced_config_json}")?;
                file.flush()?;
                config_cache.save().await?;
            }
        }

        Ok(())
    }
}

impl TaskAccessProvider for TaskAccess {
    fn is_enabled(&self) -> bool {
        TaskAccess::is_enabled(self)
    }

    fn get_env_var(&self, task_hash: &str) -> (String, AbsoluteSystemPathBuf) {
        TaskAccess::get_env_var(self, task_hash)
    }

    fn can_cache(&self, task_hash: &str, task_id: &str) -> Option<bool> {
        TaskAccess::can_cache(self, task_hash, task_id)
    }

    async fn save(&self) {
        TaskAccess::save(self).await
    }
}

#[cfg(test)]
mod tests {
    use tempfile::tempdir;

    use super::*;

    fn trace_with_outputs(outputs: Vec<UnescapedString>) -> TaskAccessTraceFile {
        TaskAccessTraceFile {
            accessed: TaskAccessTraceAccess {
                network: false,
                file_paths: Vec::new(),
                env_var_keys: Vec::new(),
            },
            outputs,
        }
    }

    #[test]
    fn can_cache_allows_generated_outputs_inside_repo() {
        let temp = tempdir().unwrap();
        let repo_root = AbsoluteSystemPathBuf::try_from(temp.path()).unwrap();
        let trace = trace_with_outputs(vec!["dist/**".into(), "apps/web/.next/**".into()]);

        assert!(trace.can_cache(&repo_root));
    }

    #[test]
    fn can_cache_rejects_accessed_file_outside_repo() {
        let repo = tempdir().unwrap();
        let outside = tempdir().unwrap();
        let repo_root = AbsoluteSystemPathBuf::try_from(repo.path()).unwrap();
        let outside_file = outside.path().join("secret.txt");
        std::fs::write(&outside_file, b"secret").unwrap();
        let trace = TaskAccessTraceFile {
            accessed: TaskAccessTraceAccess {
                network: false,
                file_paths: vec![outside_file.to_string_lossy().into_owned().into()],
                env_var_keys: Vec::new(),
            },
            outputs: Vec::new(),
        };

        assert!(!trace.can_cache(&repo_root));
    }

    #[test]
    fn can_cache_rejects_generated_absolute_output_outside_repo() {
        let repo = tempdir().unwrap();
        let outside = tempdir().unwrap();
        let repo_root = AbsoluteSystemPathBuf::try_from(repo.path()).unwrap();
        let outside_output = outside.path().join("dist");
        let trace = trace_with_outputs(vec![outside_output.to_string_lossy().into_owned().into()]);

        assert!(!trace.can_cache(&repo_root));
    }

    #[test]
    fn can_cache_rejects_generated_relative_output_that_traverses_outside_repo() {
        let temp = tempdir().unwrap();
        let repo_root = AbsoluteSystemPathBuf::try_from(temp.path()).unwrap();
        let trace = trace_with_outputs(vec!["../outside/**".into()]);

        assert!(!trace.can_cache(&repo_root));
    }
}
