use std::path::Path;

use indexmap::IndexMap;
use regex::Regex;
use std::sync::LazyLock;

use crate::git_utils::{detect_remote_name, git_cmd};
use crate::model::{VarOrigin, VarValue};

/// Git context detected from the repository containing the CI file.
#[derive(Debug, Clone, Default)]
pub struct GitContext {
    pub commit_sha: String,
    pub branch: String,
    pub commit_title: String,
    pub commit_message: String,
    pub commit_author: String,
    /// GitLab server URL, e.g. `https://gitlab.freedesktop.org`
    pub server_url: String,
    /// Full project path, e.g. "group/subgroup/project"
    pub project_path: String,
    /// Optional pipeline ID override (from --pipeline-id CLI flag)
    pub pipeline_id: Option<String>,
}

impl GitContext {
    /// Detect git context from a file or directory path.
    /// If the path is a directory, it is used directly; if a file, its parent is used.
    pub fn detect(file_path: &Path) -> Self {
        let dir = if file_path.is_dir() {
            file_path
        } else {
            file_path.parent().unwrap_or_else(|| Path::new("."))
        };

        let commit_sha =
            git_cmd(dir, &["rev-parse", "HEAD"]).unwrap_or_else(|| "⟨commit-sha⟩".to_string());

        let branch = git_cmd(dir, &["rev-parse", "--abbrev-ref", "HEAD"])
            .unwrap_or_else(|| "⟨branch⟩".to_string());

        let commit_title = git_cmd(dir, &["log", "-1", "--format=%s"])
            .unwrap_or_else(|| "⟨commit-title⟩".to_string());

        let commit_message = git_cmd(dir, &["log", "-1", "--format=%B"])
            .unwrap_or_else(|| "⟨commit-message⟩".to_string());

        let commit_author = git_cmd(dir, &["log", "-1", "--format=%aN <%aE>"])
            .unwrap_or_else(|| "⟨commit-author⟩".to_string());

        let remote_name = detect_remote_name(dir);
        let remote_url = git_cmd(dir, &["remote", "get-url", &remote_name]).unwrap_or_default();
        let (server_url, project_path) = parse_remote_url(&remote_url);

        GitContext {
            commit_sha,
            branch,
            commit_title,
            commit_message,
            commit_author,
            server_url,
            project_path,
            pipeline_id: None,
        }
    }
}

/// Extract server URL and project path from a git remote URL.
fn parse_remote_url(remote: &str) -> (String, String) {
    let (server, path) = if remote.starts_with("https://") || remote.starts_with("http://") {
        if let Ok(url) = url::Url::parse(remote) {
            let server = format!("{}://{}", url.scheme(), url.host_str().unwrap_or(""));
            let path = url.path().trim_start_matches('/').trim_end_matches(".git");
            (server, path.to_string())
        } else {
            return (String::new(), String::new());
        }
    } else if remote.starts_with("ssh://") {
        if let Ok(url) = url::Url::parse(remote) {
            let host = url.host_str().unwrap_or("");
            let api_host = host.strip_prefix("ssh.").unwrap_or(host);
            let server = format!("https://{}", api_host);
            let path = url.path().trim_start_matches('/').trim_end_matches(".git");
            (server, path.to_string())
        } else {
            return (String::new(), String::new());
        }
    } else if let Some(at_pos) = remote.find('@') {
        // git@host:group/project.git
        let after_at = &remote[at_pos + 1..];
        if let Some(colon_pos) = after_at.find(':') {
            let host = &after_at[..colon_pos];
            let api_host = host.strip_prefix("ssh.").unwrap_or(host);
            let server = format!("https://{}", api_host);
            let path = after_at[colon_pos + 1..].trim_end_matches(".git");
            (server, path.to_string())
        } else {
            (String::new(), String::new())
        }
    } else {
        (String::new(), String::new())
    };

    (server, path)
}

static VAR_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
    // Matches $VAR, ${VAR}, ${VAR:-default}
    Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}|\$([A-Za-z_][A-Za-z0-9_]*)").unwrap()
});

/// Build the merged variable map: global < job-level.
/// Returns fully expanded string values.
#[cfg(test)]
pub fn merge_and_expand_variables(
    global_vars: &IndexMap<String, VarValue>,
    job_vars: &Option<IndexMap<String, VarValue>>,
    job_name: &str,
    stage: &str,
    git: &GitContext,
) -> IndexMap<String, String> {
    let (vars, _) =
        merge_and_expand_variables_with_origins(global_vars, job_vars, job_name, stage, git);
    vars
}

/// Like `merge_and_expand_variables`, but also returns the origin of each variable.
pub fn merge_and_expand_variables_with_origins(
    global_vars: &IndexMap<String, VarValue>,
    job_vars: &Option<IndexMap<String, VarValue>>,
    job_name: &str,
    stage: &str,
    git: &GitContext,
) -> (IndexMap<String, String>, IndexMap<String, VarOrigin>) {
    let mut merged: IndexMap<String, String> = IndexMap::new();
    let mut origins: IndexMap<String, VarOrigin> = IndexMap::new();

    // Global vars first
    for (k, v) in global_vars {
        merged.insert(k.clone(), v.value().to_string());
        origins.insert(k.clone(), VarOrigin::Global);
    }

    // Job vars override
    if let Some(job_vars) = job_vars {
        for (k, v) in job_vars {
            let origin = if origins.contains_key(k) {
                VarOrigin::GlobalOverriddenByJob
            } else {
                VarOrigin::Job
            };
            merged.insert(k.clone(), v.value().to_string());
            origins.insert(k.clone(), origin);
        }
    }

    // Add common CI predefined variable stubs
    add_predefined_stubs(&mut merged, job_name, stage, git);
    // Mark any newly added keys as predefined
    for k in merged.keys() {
        origins.entry(k.clone()).or_insert(VarOrigin::Predefined);
    }

    // Expand variable references within values (iterate until stable,
    // since variables may reference other variables that need expansion).
    // Self-references (e.g. PYTHONPATH="$PYTHONPATH:...") are skipped
    // so they remain as $VAR for the shell to resolve at runtime.
    for _ in 0..10 {
        let snapshot = merged.clone();
        let mut changed = false;
        for (key, value) in merged.iter_mut() {
            let expanded = expand_vars_except(value, &snapshot, key);
            if expanded != *value {
                *value = expanded;
                changed = true;
            }
        }
        if !changed {
            break;
        }
    }

    (merged, origins)
}

/// Expand `$VAR` and `${VAR:-default}` references in a string.
pub fn expand_vars(input: &str, vars: &IndexMap<String, String>) -> String {
    expand_vars_except(input, vars, "")
}

/// Like `expand_vars`, but skip references to `skip_var` (used to avoid
/// infinite self-referencing expansion, e.g. `PYTHONPATH="$PYTHONPATH:..."`)
fn expand_vars_except(input: &str, vars: &IndexMap<String, String>, skip_var: &str) -> String {
    VAR_PATTERN
        .replace_all(input, |caps: &regex::Captures| {
            // ${VAR:-default} form
            if let Some(name) = caps.get(1) {
                let name = name.as_str();
                if name == skip_var {
                    return caps[0].to_string();
                }
                match vars.get(name) {
                    Some(val) if !val.is_empty() => val.clone(),
                    _ => caps
                        .get(2)
                        .map(|m| m.as_str().to_string())
                        .unwrap_or_else(|| format!("${{{}}}", name)),
                }
            }
            // $VAR form
            else if let Some(name) = caps.get(3) {
                let name = name.as_str();
                if name == skip_var {
                    return caps[0].to_string();
                }
                vars.get(name)
                    .cloned()
                    .unwrap_or_else(|| format!("${}", name))
            } else {
                caps[0].to_string()
            }
        })
        .to_string()
}

/// Expand variables in a list of script lines.
pub fn expand_script_vars(lines: &[String], vars: &IndexMap<String, String>) -> Vec<String> {
    lines.iter().map(|line| expand_vars(line, vars)).collect()
}

fn add_predefined_stubs(
    vars: &mut IndexMap<String, String>,
    job_name: &str,
    stage: &str,
    git: &GitContext,
) {
    // Use pipeline ID override if provided, otherwise derive from commit SHA
    let pipeline_id = git.pipeline_id.clone().unwrap_or_else(|| {
        u32::from_str_radix(&git.commit_sha[..git.commit_sha.len().min(8)], 16)
            .unwrap_or(123456789)
            .to_string()
    });

    let builds_dir = crate::container::builds_dir(&git.project_path);

    let project_url = if !git.server_url.is_empty() && !git.project_path.is_empty() {
        format!("{}/{}", git.server_url, git.project_path)
    } else {
        "⟨project-url⟩".to_string()
    };

    let project_name = git.project_path.rsplit('/').next().unwrap_or("⟨project⟩");

    let project_namespace = git
        .project_path
        .rsplit_once('/')
        .map(|x| x.0)
        .unwrap_or("⟨namespace⟩");

    // Derive CI_REGISTRY from the server URL.
    // For "gitlab.example.com" (3+ parts), replace "gitlab." with "registry."
    // e.g. gitlab.freedesktop.org -> registry.freedesktop.org
    // For "gitlab.com" (2 parts), prepend "registry."
    // e.g. gitlab.com -> registry.gitlab.com
    let registry = git
        .server_url
        .strip_prefix("https://")
        .or_else(|| git.server_url.strip_prefix("http://"))
        .map(|host| {
            if let Some(rest) = host.strip_prefix("gitlab.") {
                if rest.contains('.') {
                    format!("registry.{}", rest)
                } else {
                    format!("registry.{}", host)
                }
            } else {
                format!("registry.{}", host)
            }
        })
        .unwrap_or_else(|| "⟨registry⟩".to_string());

    let registry_image = if registry != "⟨registry⟩" && !git.project_path.is_empty() {
        format!("{}/{}", registry, git.project_path)
    } else {
        "⟨registry-image⟩".to_string()
    };

    let server_host = git
        .server_url
        .trim_start_matches("https://")
        .trim_start_matches("http://");

    let server_protocol = if git.server_url.starts_with("https://") {
        "https"
    } else if git.server_url.starts_with("http://") {
        "http"
    } else {
        "https"
    };

    let api_v4_url = if !git.server_url.is_empty() {
        format!("{}/api/v4", git.server_url)
    } else {
        "⟨api-url⟩".to_string()
    };

    let api_graphql_url = if !git.server_url.is_empty() {
        format!("{}/api/graphql", git.server_url)
    } else {
        "⟨api-graphql-url⟩".to_string()
    };

    let pipeline_url = if !project_url.contains("⟨project-url⟩") {
        format!("{}/pipelines/{}", project_url, pipeline_id)
    } else {
        "⟨pipeline-url⟩".to_string()
    };

    let ref_slug = git
        .branch
        .to_lowercase()
        .replace(|c: char| !c.is_alphanumeric() && c != '-', "-")
        .trim_matches('-')
        .to_string();

    // ISO 8601 timestamp for job start time
    let now = {
        use std::time::{SystemTime, UNIX_EPOCH};
        let secs = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        unix_timestamp_to_iso8601(secs)
    };

    let stubs = [
        // Core CI flags
        ("CI", "true"),
        ("GITLAB_CI", "true"),
        // Pipeline
        ("CI_PIPELINE_ID", pipeline_id.as_str()),
        ("CI_PIPELINE_IID", pipeline_id.as_str()),
        ("CI_PIPELINE_SOURCE", "push"),
        ("CI_PIPELINE_URL", pipeline_url.as_str()),
        // Job
        ("CI_JOB_ID", "0"),
        ("CI_JOB_NAME", job_name),
        ("CI_JOB_STAGE", stage),
        ("CI_JOB_URL", "⟨job-url⟩"),
        ("CI_JOB_STARTED_AT", now.as_str()),
        // Commit
        ("CI_COMMIT_SHA", git.commit_sha.as_str()),
        (
            "CI_COMMIT_SHORT_SHA",
            &git.commit_sha[..git.commit_sha.len().min(8)],
        ),
        ("CI_COMMIT_REF_NAME", git.branch.as_str()),
        ("CI_COMMIT_BRANCH", git.branch.as_str()),
        ("CI_COMMIT_REF_SLUG", ref_slug.as_str()),
        ("CI_COMMIT_REF_PROTECTED", "false"),
        ("CI_COMMIT_TITLE", git.commit_title.as_str()),
        ("CI_COMMIT_MESSAGE", git.commit_message.as_str()),
        ("CI_COMMIT_AUTHOR", git.commit_author.as_str()),
        // Project
        ("CI_PROJECT_ID", "0"),
        ("CI_PROJECT_NAME", project_name),
        ("CI_PROJECT_NAMESPACE", project_namespace),
        ("CI_PROJECT_PATH", git.project_path.as_str()),
        ("CI_PROJECT_URL", project_url.as_str()),
        ("CI_PROJECT_DIR", &builds_dir),
        ("CI_PROJECT_VISIBILITY", "public"),
        ("CI_DEFAULT_BRANCH", "main"),
        // Server
        ("CI_SERVER_URL", git.server_url.as_str()),
        ("CI_SERVER_HOST", server_host),
        ("CI_SERVER_PROTOCOL", server_protocol),
        ("CI_SERVER_PORT", "443"),
        ("CI_API_V4_URL", api_v4_url.as_str()),
        ("CI_API_GRAPHQL_URL", api_graphql_url.as_str()),
        // Registry
        ("CI_REGISTRY", registry.as_str()),
        ("CI_REGISTRY_IMAGE", registry_image.as_str()),
        ("CI_REGISTRY_USER", "gitlab-ci-token"),
        ("CI_REGISTRY_PASSWORD", "⟨registry-password⟩"),
        // Parallel
        ("CI_NODE_INDEX", "1"),
        ("CI_NODE_TOTAL", "1"),
        // User
        ("GITLAB_USER_LOGIN", "⟨user⟩"),
        ("GITLAB_USER_EMAIL", "⟨user⟩@example.com"),
        ("GITLAB_USER_NAME", "⟨User Name⟩"),
        ("GITLAB_USER_ID", "0"),
    ];
    for (k, v) in stubs {
        vars.entry(k.to_string()).or_insert_with(|| v.to_string());
    }
}

/// Convert Unix epoch seconds to an ISO 8601 UTC timestamp (YYYY-MM-DDTHH:MM:SSZ).
fn unix_timestamp_to_iso8601(secs: u64) -> String {
    let days = secs / 86400;
    let time_of_day = secs % 86400;
    let hours = time_of_day / 3600;
    let minutes = (time_of_day % 3600) / 60;
    let seconds = time_of_day % 60;

    let mut y = 1970i64;
    let mut remaining = days as i64;
    loop {
        let ylen = if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) {
            366
        } else {
            365
        };
        if remaining < ylen {
            break;
        }
        remaining -= ylen;
        y += 1;
    }

    let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0);
    let mdays = [
        31,
        if leap { 29 } else { 28 },
        31,
        30,
        31,
        30,
        31,
        31,
        30,
        31,
        30,
        31,
    ];
    let mut m = 0;
    for md in &mdays {
        if remaining < *md {
            break;
        }
        remaining -= md;
        m += 1;
    }

    format!(
        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
        y,
        m + 1,
        remaining + 1,
        hours,
        minutes,
        seconds
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_expand_vars_simple() {
        let mut vars = IndexMap::new();
        vars.insert("FOO".to_string(), "bar".to_string());
        assert_eq!(expand_vars("hello $FOO", &vars), "hello bar");
    }

    #[test]
    fn test_expand_vars_braces() {
        let mut vars = IndexMap::new();
        vars.insert("FOO".to_string(), "bar".to_string());
        assert_eq!(expand_vars("hello ${FOO}", &vars), "hello bar");
    }

    #[test]
    fn test_expand_vars_default_used() {
        let vars = IndexMap::new();
        assert_eq!(expand_vars("${MISSING:-fallback}", &vars), "fallback");
    }

    #[test]
    fn test_expand_vars_default_not_used() {
        let mut vars = IndexMap::new();
        vars.insert("FOO".to_string(), "bar".to_string());
        assert_eq!(expand_vars("${FOO:-fallback}", &vars), "bar");
    }

    #[test]
    fn test_expand_vars_default_used_when_empty() {
        let mut vars = IndexMap::new();
        vars.insert("FOO".to_string(), String::new());
        assert_eq!(expand_vars("${FOO:-fallback}", &vars), "fallback");
    }

    #[test]
    fn test_expand_vars_missing_no_default() {
        let vars = IndexMap::new();
        assert_eq!(expand_vars("$MISSING", &vars), "$MISSING");
        assert_eq!(expand_vars("${MISSING}", &vars), "${MISSING}");
    }

    #[test]
    fn test_expand_vars_multiple() {
        let mut vars = IndexMap::new();
        vars.insert("A".to_string(), "1".to_string());
        vars.insert("B".to_string(), "2".to_string());
        assert_eq!(expand_vars("$A and $B", &vars), "1 and 2");
    }

    #[test]
    fn test_expand_vars_no_vars() {
        let vars = IndexMap::new();
        assert_eq!(expand_vars("no variables here", &vars), "no variables here");
    }

    #[test]
    fn test_parse_remote_url_https() {
        let (server, path) = parse_remote_url("https://gitlab.freedesktop.org/group/project.git");
        assert_eq!(server, "https://gitlab.freedesktop.org");
        assert_eq!(path, "group/project");
    }

    #[test]
    fn test_parse_remote_url_ssh_at() {
        let (server, path) = parse_remote_url("git@ssh.gitlab.freedesktop.org:group/project.git");
        assert_eq!(server, "https://gitlab.freedesktop.org");
        assert_eq!(path, "group/project");
    }

    #[test]
    fn test_parse_remote_url_ssh_scheme() {
        let (server, path) =
            parse_remote_url("ssh://git@ssh.gitlab.freedesktop.org/group/project.git");
        assert_eq!(server, "https://gitlab.freedesktop.org");
        assert_eq!(path, "group/project");
    }

    #[test]
    fn test_parse_remote_url_gitlab_com() {
        let (server, path) = parse_remote_url("git@gitlab.com:user/repo.git");
        assert_eq!(server, "https://gitlab.com");
        assert_eq!(path, "user/repo");
    }

    #[test]
    fn test_parse_remote_url_empty() {
        let (server, path) = parse_remote_url("");
        assert!(server.is_empty());
        assert!(path.is_empty());
    }

    #[test]
    fn test_parse_remote_url_subgroup() {
        let (server, path) = parse_remote_url("https://gitlab.com/group/subgroup/project.git");
        assert_eq!(server, "https://gitlab.com");
        assert_eq!(path, "group/subgroup/project");
    }

    #[test]
    fn test_predefined_stubs_registry_self_hosted() {
        let git = GitContext {
            server_url: "https://gitlab.freedesktop.org".to_string(),
            project_path: "group/project".to_string(),
            ..Default::default()
        };
        let mut vars = IndexMap::new();
        add_predefined_stubs(&mut vars, "job", "build", &git);
        assert_eq!(vars["CI_REGISTRY"], "registry.freedesktop.org");
        assert_eq!(
            vars["CI_REGISTRY_IMAGE"],
            "registry.freedesktop.org/group/project"
        );
    }

    #[test]
    fn test_predefined_stubs_registry_gitlab_com() {
        let git = GitContext {
            server_url: "https://gitlab.com".to_string(),
            project_path: "user/repo".to_string(),
            ..Default::default()
        };
        let mut vars = IndexMap::new();
        add_predefined_stubs(&mut vars, "job", "build", &git);
        assert_eq!(vars["CI_REGISTRY"], "registry.gitlab.com");
    }

    #[test]
    fn test_predefined_stubs_job_and_stage() {
        let git = GitContext::default();
        let mut vars = IndexMap::new();
        add_predefined_stubs(&mut vars, "my-job", "test", &git);
        assert_eq!(vars["CI_JOB_NAME"], "my-job");
        assert_eq!(vars["CI_JOB_STAGE"], "test");
    }

    #[test]
    fn test_predefined_stubs_ref_slug() {
        let git = GitContext {
            branch: "feature/my-BRANCH_123".to_string(),
            ..Default::default()
        };
        let mut vars = IndexMap::new();
        add_predefined_stubs(&mut vars, "job", "build", &git);
        assert_eq!(vars["CI_COMMIT_REF_SLUG"], "feature-my-branch-123");
    }

    #[test]
    fn test_predefined_stubs_project_namespace() {
        let git = GitContext {
            project_path: "group/subgroup/project".to_string(),
            ..Default::default()
        };
        let mut vars = IndexMap::new();
        add_predefined_stubs(&mut vars, "job", "build", &git);
        assert_eq!(vars["CI_PROJECT_NAME"], "project");
        assert_eq!(vars["CI_PROJECT_NAMESPACE"], "group/subgroup");
    }

    #[test]
    fn test_predefined_stubs_do_not_overwrite_user_vars() {
        let git = GitContext::default();
        let mut vars = IndexMap::new();
        vars.insert("CI".to_string(), "custom".to_string());
        add_predefined_stubs(&mut vars, "job", "build", &git);
        assert_eq!(vars["CI"], "custom");
    }

    #[test]
    fn test_expand_script_vars() {
        let mut vars = IndexMap::new();
        vars.insert("NAME".to_string(), "world".to_string());
        let lines = vec!["echo hello $NAME".to_string(), "echo done".to_string()];
        let result = expand_script_vars(&lines, &vars);
        assert_eq!(result[0], "echo hello world");
        assert_eq!(result[1], "echo done");
    }

    #[test]
    fn test_unix_timestamp_epoch() {
        assert_eq!(unix_timestamp_to_iso8601(0), "1970-01-01T00:00:00Z");
    }

    #[test]
    fn test_unix_timestamp_known_date() {
        // 2024-01-01T00:00:00Z = 1704067200
        assert_eq!(
            unix_timestamp_to_iso8601(1704067200),
            "2024-01-01T00:00:00Z"
        );
    }

    #[test]
    fn test_unix_timestamp_leap_year() {
        // 2024-02-29T12:30:45Z = 1709209845 (2024 is a leap year)
        assert_eq!(
            unix_timestamp_to_iso8601(1709209845),
            "2024-02-29T12:30:45Z"
        );
    }

    #[test]
    fn test_unix_timestamp_end_of_year() {
        // 2023-12-31T23:59:59Z = 1704067199
        assert_eq!(
            unix_timestamp_to_iso8601(1704067199),
            "2023-12-31T23:59:59Z"
        );
    }

    #[test]
    fn test_multi_level_variable_expansion() {
        // A references B, B references C — should fully resolve in multiple passes
        let mut global: IndexMap<String, VarValue> = IndexMap::new();
        global.insert("C".to_string(), VarValue::Simple("value".to_string()));
        global.insert("B".to_string(), VarValue::Simple("$C-suffix".to_string()));
        global.insert("A".to_string(), VarValue::Simple("prefix-$B".to_string()));
        let git = GitContext::default();
        let vars = merge_and_expand_variables(&global, &None, "job", "test", &git);
        assert_eq!(vars["C"], "value");
        assert_eq!(vars["B"], "value-suffix");
        assert_eq!(vars["A"], "prefix-value-suffix");
    }

    #[test]
    fn test_user_vars_referencing_predefined() {
        // User variables that reference predefined CI_* vars should expand
        let mut global: IndexMap<String, VarValue> = IndexMap::new();
        global.insert(
            "MY_IMAGE".to_string(),
            VarValue::Simple("${CI_REGISTRY_IMAGE}/my-app".to_string()),
        );
        let git = GitContext {
            server_url: "https://gitlab.com".to_string(),
            project_path: "user/repo".to_string(),
            ..Default::default()
        };
        let vars = merge_and_expand_variables(&global, &None, "job", "test", &git);
        assert_eq!(vars["MY_IMAGE"], "registry.gitlab.com/user/repo/my-app");
    }

    #[test]
    fn test_detect_works_with_directory_path() {
        let dir = tempfile::tempdir().unwrap();
        std::process::Command::new("git")
            .args(["init"])
            .current_dir(dir.path())
            .output()
            .unwrap();
        std::process::Command::new("git")
            .args([
                "-c",
                "user.name=Test",
                "-c",
                "user.email=test@test",
                "commit",
                "--allow-empty",
                "-m",
                "test",
            ])
            .current_dir(dir.path())
            .output()
            .unwrap();
        let sha_output = std::process::Command::new("git")
            .args(["rev-parse", "HEAD"])
            .current_dir(dir.path())
            .output()
            .unwrap();
        let expected_sha = String::from_utf8(sha_output.stdout)
            .unwrap()
            .trim()
            .to_string();

        // Passing a directory should work the same as passing a file inside it
        let from_dir = GitContext::detect(dir.path());
        let from_file = GitContext::detect(&dir.path().join(".gitlab-ci.yml"));
        assert_eq!(from_dir.commit_sha, expected_sha);
        assert_eq!(from_file.commit_sha, expected_sha);
    }

    #[test]
    fn test_predefined_placeholders_are_shell_safe() {
        let git = GitContext::default();
        let mut vars = IndexMap::new();
        add_predefined_stubs(&mut vars, "job", "build", &git);
        for (k, v) in &vars {
            assert!(
                !v.contains('<') && !v.contains('>'),
                "variable {} = {:?} contains shell-unsafe angle brackets",
                k,
                v
            );
        }
    }

    #[test]
    fn test_self_referencing_variable_not_expanded() {
        // PYTHONPATH="$PYTHONPATH:/foo" should keep $PYTHONPATH for the shell
        let mut global: IndexMap<String, VarValue> = IndexMap::new();
        global.insert(
            "PYTHONPATH".to_string(),
            VarValue::Simple("$PYTHONPATH:/foo/bar".to_string()),
        );
        let git = GitContext::default();
        let vars = merge_and_expand_variables(&global, &None, "job", "test", &git);
        assert_eq!(vars["PYTHONPATH"], "$PYTHONPATH:/foo/bar");
    }
}
