//! Artifact transfer between jobs in a dispatch run.
//!
//! When `--dispatch` runs multiple jobs, earlier jobs may produce artifacts
//! that later jobs depend on (via `needs:`). This module copies artifact files
//! out of completed jobs and injects them into the working directory of
//! downstream jobs, matching the `artifacts: paths:` declarations from the CI
//! config.

use std::fs;
use std::io::{self, Write as _};
use std::path::PathBuf;
use std::process::{Command, Stdio};

use anyhow::{Context, Result, bail};
use indexmap::IndexMap;
use log::{debug, info, warn};

use crate::container::OutputConfig;
use crate::model::ResolvedNeed;
use crate::parser::gitlab_fetch::{GitlabConfig, cache_dir, urlencoded};

/// A conflict: both local and remote artifacts exist for a needed job.
#[derive(Debug)]
pub struct ArtifactConflict {
    pub job_name: String,
    pub local_path: PathBuf,
    pub remote_path: PathBuf,
}

/// Result of resolving artifacts for a job's needs.
#[derive(Debug)]
pub struct ArtifactResolution {
    /// Artifacts ready to inject (source already chosen or only one exists).
    pub ready: Vec<(String, PathBuf)>,
    /// Conflicts requiring user input.
    pub conflicts: Vec<ArtifactConflict>,
}

/// Convert a project path like "mesa/mesa" to a filesystem-safe slug "mesa--mesa".
pub(crate) fn project_slug(project_path: &str) -> String {
    project_path.replace('/', "--")
}

/// Return the artifact cache directory for a project + pipeline.
/// `<cache_dir>/<project_slug>/artifacts/<pipeline_id>/`
fn artifact_pipeline_dir(project_path: &str, pipeline_id: &str) -> Option<PathBuf> {
    Some(
        cache_dir()?
            .join(project_slug(project_path))
            .join("artifacts")
            .join(pipeline_id),
    )
}

/// Return the local artifact cache directory for a project.
/// `<cache_dir>/<project_slug>/artifacts/local/`
pub(crate) fn artifact_local_dir(project_path: &str) -> Option<PathBuf> {
    Some(
        cache_dir()?
            .join(project_slug(project_path))
            .join("artifacts")
            .join("local"),
    )
}

/// Maximum size for a dotenv report file (matches GitLab's 5 KiB limit).
pub(crate) const MAX_DOTENV_SIZE: u64 = 5 * 1024;

/// Sanitize a job name for use as a cache filename.
///
/// Replaces any character that is not alphanumeric, `-`, or `_` with `_`.
pub(crate) fn sanitize_job_filename(name: &str) -> String {
    name.chars()
        .map(|c| {
            if c.is_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                '_'
            }
        })
        .collect()
}

/// Parse dotenv content into key-value pairs.
///
/// Follows GitLab's dotenv format: one `KEY=VALUE` per line, no quoting,
/// no comments, keys contain only `[A-Za-z0-9_]`.
pub(crate) fn parse_dotenv(content: &str) -> IndexMap<String, String> {
    let mut vars = IndexMap::new();
    for line in content.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        if let Some((key, value)) = line.split_once('=') {
            let key = key.trim();
            if !key.is_empty() && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
                vars.insert(key.to_string(), value.trim().to_string());
            }
        }
    }
    vars
}

/// Load dotenv variables from a cached `.dotenv` file for a given job.
///
/// Returns `None` if no cached dotenv file exists for the job.
pub(crate) fn load_dotenv_vars(
    project_path: &str,
    job_name: &str,
) -> Option<IndexMap<String, String>> {
    let local_dir = artifact_local_dir(project_path)?;
    let safe_name = sanitize_job_filename(job_name);
    let dotenv_path = local_dir.join(format!("{}.dotenv", safe_name));
    let meta = std::fs::metadata(&dotenv_path).ok()?;
    if meta.len() > MAX_DOTENV_SIZE {
        warn!("cached dotenv file too large ({}), skipping", meta.len());
        return None;
    }
    let content = std::fs::read_to_string(&dotenv_path).ok()?;
    let vars = parse_dotenv(&content);
    if vars.is_empty() { None } else { Some(vars) }
}

/// Fetch artifacts for a specific job from a GitLab pipeline.
///
/// 1. GET /api/v4/projects/:id/pipelines/:pipeline_id/jobs → find job ID by name
/// 2. GET /api/v4/projects/:id/jobs/:job_id/artifacts → download zip
/// 3. Extract to cache directory
fn fetch_remote_artifacts(
    gitlab_config: &GitlabConfig,
    project_path: &str,
    pipeline_id: &str,
    job_name: &str,
    oc: &OutputConfig,
) -> Result<PathBuf> {
    let dest_dir = artifact_pipeline_dir(project_path, pipeline_id)
        .context("Cannot determine cache directory")?
        .join(job_name);

    // Already cached?
    if dest_dir.exists() {
        debug!("Artifact cache hit: {}", dest_dir.display());
        return Ok(dest_dir);
    }

    oc.status(&format!(
        "\x1b[90mFetching artifacts for job '{}' from pipeline {}...\x1b[0m",
        job_name, pipeline_id
    ));

    let encoded_project = urlencoded(project_path);

    // Step 1: List pipeline jobs to find the job ID
    let jobs_url = format!(
        "{}/api/v4/projects/{}/pipelines/{}/jobs?per_page=100",
        gitlab_config.base_url, encoded_project, pipeline_id
    );

    let job_id = fetch_job_id(gitlab_config, &jobs_url, job_name)?;

    // Step 2: Download artifacts zip
    let artifacts_url = format!(
        "{}/api/v4/projects/{}/jobs/{}/artifacts",
        gitlab_config.base_url, encoded_project, job_id
    );

    oc.status(&format!(
        "\x1b[90mDownloading artifacts for '{}' (job #{})...\x1b[0m",
        job_name, job_id
    ));

    let mut request = gitlab_config.client().get(&artifacts_url);
    if let Some(ref token) = gitlab_config.token {
        request = request.header("PRIVATE-TOKEN", token);
    }

    let response = request
        .send()
        .with_context(|| format!("Failed to fetch artifacts from {}", artifacts_url))?;

    if !response.status().is_success() {
        bail!(
            "Failed to download artifacts for '{}' (HTTP {}). Set GITLAB_TOKEN for private projects.",
            job_name,
            response.status()
        );
    }

    let bytes = crate::parser::gitlab::fetch::read_response_bytes(
        response,
        crate::parser::gitlab::fetch::MAX_DOWNLOAD_SIZE,
    )
    .context("Failed to read artifact response body")?;

    // Step 3: Extract zip to cache directory
    fs::create_dir_all(&dest_dir)?;
    let cursor = io::Cursor::new(&bytes);
    let mut archive = zip::ZipArchive::new(cursor).context("Failed to read artifacts zip")?;

    for i in 0..archive.len() {
        let mut file = archive.by_index(i)?;
        let Some(name) = file.enclosed_name() else {
            continue;
        };
        let out_path = dest_dir.join(name);

        if file.is_dir() {
            fs::create_dir_all(&out_path)?;
        } else {
            if let Some(parent) = out_path.parent() {
                fs::create_dir_all(parent)?;
            }
            let mut out_file = fs::File::create(&out_path)?;
            io::copy(&mut file, &mut out_file)?;

            // Preserve Unix permissions (especially +x on binaries)
            #[cfg(unix)]
            if let Some(mode) = file.unix_mode() {
                use std::os::unix::fs::PermissionsExt;
                fs::set_permissions(&out_path, fs::Permissions::from_mode(mode))?;
            }
        }
    }

    oc.status(&format!(
        "\x1b[32mCached artifacts for '{}' ({} bytes)\x1b[0m",
        job_name,
        bytes.len()
    ));

    Ok(dest_dir)
}

/// Find a job's numeric ID by name from the pipeline jobs listing.
/// Handles pagination and fuzzy matching for matrix-expanded jobs.
fn fetch_job_id(gitlab_config: &GitlabConfig, url: &str, job_name: &str) -> Result<u64> {
    let base_url = url.to_string();
    let mut page: Option<String> = None;
    let mut all_jobs: Vec<(String, u64)> = Vec::new();

    loop {
        let page_url = match &page {
            Some(p) => format!("{}&page={}", base_url, p),
            None => base_url.clone(),
        };
        let mut request = gitlab_config.client().get(&page_url);
        if let Some(ref token) = gitlab_config.token {
            request = request.header("PRIVATE-TOKEN", token);
        }

        let response = request
            .send()
            .with_context(|| format!("Failed to list pipeline jobs from {}", page_url))?;

        if !response.status().is_success() {
            bail!(
                "Failed to list pipeline jobs (HTTP {}). Set GITLAB_TOKEN for private projects.",
                response.status()
            );
        }

        // Check for next page header before consuming body
        let next_page = response
            .headers()
            .get("x-next-page")
            .and_then(|v| v.to_str().ok())
            .and_then(|s| {
                if s.is_empty() {
                    None
                } else {
                    Some(s.to_string())
                }
            });

        let body = crate::parser::gitlab::fetch::read_response_text(
            response,
            crate::parser::gitlab::fetch::MAX_RESPONSE_SIZE,
        )
        .context("Failed to read jobs response")?;
        let jobs: Vec<serde_json::Value> =
            serde_json::from_str(&body).context("Failed to parse pipeline jobs JSON")?;

        for job in &jobs {
            let name = job["name"].as_str().unwrap_or("");
            let id = job["id"].as_u64().unwrap_or(0);
            all_jobs.push((name.to_string(), id));

            // Exact match
            if name == job_name {
                return Ok(id);
            }
        }

        // Try next page
        if let Some(next) = next_page {
            page = Some(next);
        } else {
            break;
        }
    }

    // Fuzzy match: job name might be a prefix (matrix expansion)
    // e.g., "build" matches "build [x86]"
    let fuzzy_matches: Vec<_> = all_jobs
        .iter()
        .filter(|(n, _)| n.starts_with(job_name))
        .collect();
    if fuzzy_matches.len() == 1 {
        warn!(
            "No exact match for job '{}', using fuzzy match '{}'",
            job_name, fuzzy_matches[0].0
        );
        return Ok(fuzzy_matches[0].1);
    }

    let all_names: Vec<_> = all_jobs.iter().map(|(n, _)| n.as_str()).collect();
    bail!(
        "Job '{}' not found in pipeline. Available jobs: {}",
        job_name,
        all_names.join(", ")
    );
}

/// Extract a tar archive into `dest_dir`, skipping entries with absolute paths
/// or parent directory (`..`) traversal components.
#[cfg(test)]
fn safe_untar(data: &[u8], dest_dir: &std::path::Path) -> Result<()> {
    let mut archive = tar::Archive::new(data);
    for entry in archive.entries().context("Failed to read tar entries")? {
        let mut entry = entry.context("Failed to read tar entry")?;
        let path = entry
            .path()
            .context("Invalid path in tar entry")?
            .into_owned();

        if path.is_absolute()
            || path
                .components()
                .any(|c| c == std::path::Component::ParentDir)
        {
            warn!("Skipping tar entry with unsafe path: {}", path.display());
            continue;
        }

        entry
            .unpack_in(dest_dir)
            .with_context(|| format!("Failed to extract {}", path.display()))?;
    }
    Ok(())
}

/// Size of a standard empty tar archive (two 512-byte end-of-archive blocks,
/// padded to one 10240-byte record).
const EMPTY_TAR_SIZE: usize = 10240;

/// Check whether tar output is an empty archive (no entries).
/// `tar` produces a [`EMPTY_TAR_SIZE`]-byte all-zeros archive when no files match.
pub(crate) fn is_empty_tar(data: &[u8]) -> bool {
    data.len() == EMPTY_TAR_SIZE && data.iter().all(|&b| b == 0)
}

/// Build the container command for extracting artifact files via tar.
///
/// Patterns are passed as positional parameters to `sh -c`, not interpolated
/// into the command string. Unquoted `$@` provides glob expansion.
/// Skips `--pull never` for the macOS `container` runtime which doesn't
/// support that flag.
fn build_tar_extract_command(
    runtime: &str,
    volume_name: &str,
    build_dir: &str,
    git_image: &str,
    artifacts_paths: &[String],
    artifacts_exclude: &[String],
    untracked: bool,
) -> Command {
    let vol_mount = format!("{}:{}", volume_name, build_dir);
    let mut cmd = Command::new(runtime);
    cmd.args(["run", "--rm"]);
    if runtime != "container" {
        cmd.args(["--pull", "never"]);
    }

    let script = if untracked {
        // When untracked is true, tar the entire working directory (excluding
        // .git). We can't use `git ls-files -o` because the extraction runs
        // in a separate container where the git worktree metadata may not be
        // accessible. This matches GitLab behavior: untracked adds all files
        // not in the git index alongside any explicit artifact paths.
        "cd \"$1\" && shift && tar -cf - --exclude=.git $@ . 2>/dev/null"
    } else {
        "cd \"$1\" && shift && tar -cf - $@ 2>/dev/null"
    };

    cmd.args([
        "--entrypoint",
        "sh",
        "-v",
        &vol_mount,
        "-w",
        build_dir,
        git_image,
        "-c",
        script,
        "_",
        build_dir,
    ]);
    for pattern in artifacts_exclude {
        cmd.arg(format!("--exclude={}", pattern));
    }
    for pattern in artifacts_paths {
        cmd.arg(pattern);
    }
    cmd
}

/// Extract artifacts from a project volume after a dispatch-mode container run.
///
/// Runs `tar` inside a container on the project volume to collect files matching
/// the artifact patterns, and saves the raw tar archive to the local cache.
/// Returns the tar file path on success, or `None` if no files were found.
///
/// Artifacts are stored as `.tar` files rather than extracted directories to
/// avoid unnecessary tar/untar cycles when transferring between runners.
#[allow(clippy::too_many_arguments)]
pub fn extract_artifacts_from_volume(
    runtime: &str,
    volume_name: &str,
    git_image: &str,
    job_name: &str,
    project_path: &str,
    build_dir: &str,
    artifacts_paths: &[String],
    artifacts_exclude: &[String],
    untracked: bool,
    oc: &OutputConfig,
) -> Result<Option<PathBuf>> {
    if artifacts_paths.is_empty() && !untracked {
        return Ok(None);
    }

    let local_dir = artifact_local_dir(project_path).context("Cannot determine cache directory")?;
    let dest_tar = local_dir.join(format!("{}.tar", job_name));

    oc.status(&format!(
        "\x1b[90mSaving artifacts for '{}'...\x1b[0m",
        job_name
    ));

    let mut cmd = build_tar_extract_command(
        runtime,
        volume_name,
        build_dir,
        git_image,
        artifacts_paths,
        artifacts_exclude,
        untracked,
    );
    let output = cmd
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .output()
        .context("Failed to extract artifacts from volume")?;

    if output.stdout.is_empty() || is_empty_tar(&output.stdout) {
        debug!("No artifact files found for job '{}' in volume", job_name);
        return Ok(None);
    }

    // Save raw tar bytes directly (no extraction)
    fs::create_dir_all(&local_dir)?;
    fs::write(&dest_tar, &output.stdout)?;

    oc.status(&format!(
        "\x1b[32mSaved artifacts for '{}' ({} bytes)\x1b[0m",
        job_name,
        output.stdout.len()
    ));

    Ok(Some(dest_tar))
}

/// Extract artifacts from a host directory (used with `HostWorktree` mount).
///
/// Tars matching files directly from the host directory without needing a
/// container. Returns the tar file path on success, or `None` if no files
/// matched.
pub fn extract_artifacts_from_dir(
    dir: &std::path::Path,
    job_name: &str,
    project_path: &str,
    artifacts_paths: &[String],
    artifacts_exclude: &[String],
    untracked: bool,
    oc: &OutputConfig,
) -> anyhow::Result<Option<std::path::PathBuf>> {
    if artifacts_paths.is_empty() && !untracked {
        return Ok(None);
    }

    let local_dir = artifact_local_dir(project_path).context("Cannot determine cache directory")?;
    let dest_tar = local_dir.join(format!("{}.tar", job_name));

    oc.status(&format!(
        "\x1b[90mSaving artifacts for '{}'...\x1b[0m",
        job_name
    ));

    let exclude_args: Vec<String> = artifacts_exclude
        .iter()
        .map(|p| format!("--exclude={}", p))
        .collect();
    let script = if untracked {
        "tar cf - --exclude=.git $@ . 2>/dev/null"
    } else {
        "tar cf - $@ 2>/dev/null"
    };
    let output = std::process::Command::new("sh")
        .args(["-c", script, "_"])
        .args(&exclude_args)
        .args(artifacts_paths)
        .current_dir(dir)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::null())
        .output()
        .context("Failed to extract artifacts from directory")?;

    if output.stdout.is_empty() || is_empty_tar(&output.stdout) {
        log::debug!(
            "No artifact files found for job '{}' in directory",
            job_name
        );
        return Ok(None);
    }

    fs::create_dir_all(&local_dir)?;
    fs::write(&dest_tar, &output.stdout)?;

    oc.status(&format!(
        "\x1b[32mSaved artifacts for '{}' ({} bytes)\x1b[0m",
        job_name,
        output.stdout.len()
    ));

    Ok(Some(dest_tar))
}

/// Resolve which artifacts to inject for a job's needs.
///
/// For each needed job with `artifacts: true`:
/// - Check local cache
/// - Check remote cache (fetch if missing and pipeline_id is set)
/// - If both exist, record a conflict
pub fn resolve_artifacts(
    needs: &[ResolvedNeed],
    project_path: &str,
    pipeline_id: Option<&str>,
    gitlab_config: Option<&GitlabConfig>,
    oc: &OutputConfig,
) -> Result<ArtifactResolution> {
    let mut ready = Vec::new();
    let mut conflicts = Vec::new();

    for need in needs {
        if !need.artifacts {
            continue;
        }

        // Check local cache: prefer .tar file, fall back to directory
        let local_dir = artifact_local_dir(project_path).and_then(|d| {
            let tar_path = d.join(format!("{}.tar", &need.job_name));
            if tar_path.exists() {
                return Some(tar_path);
            }
            let dir_path = d.join(&need.job_name);
            if dir_path.exists() {
                return Some(dir_path);
            }
            None
        });

        let remote_dir = if let (Some(pid), Some(config)) = (pipeline_id, gitlab_config) {
            match fetch_remote_artifacts(config, project_path, pid, &need.job_name, oc) {
                Ok(path) => Some(path),
                Err(e) => {
                    warn!(
                        "Failed to fetch remote artifacts for '{}': {}",
                        need.job_name, e
                    );
                    // Remote fetch failed — fall back to any cached pipeline
                    find_newest_cached_artifact(project_path, &need.job_name)
                }
            }
        } else if pipeline_id.is_some() {
            // pipeline_id set but no gitlab_config — check exact cache match,
            // then fall back to the most recent cached pipeline.
            pipeline_id
                .and_then(|pid| {
                    artifact_pipeline_dir(project_path, pid)
                        .map(|d| d.join(&need.job_name))
                        .filter(|d| d.exists())
                })
                .or_else(|| find_newest_cached_artifact(project_path, &need.job_name))
        } else {
            // No --pipeline-id: only use cached remote artifacts as a
            // fallback when no local artifacts exist (don't prompt).
            None
        };

        match (local_dir, remote_dir) {
            (Some(local), Some(remote)) => {
                conflicts.push(ArtifactConflict {
                    job_name: need.job_name.clone(),
                    local_path: local,
                    remote_path: remote,
                });
            }
            (Some(path), None) | (None, Some(path)) => {
                ready.push((need.job_name.clone(), path));
            }
            (None, None) => {
                // No pipeline-id and no local artifacts: check cached
                // pipelines as a last resort (no conflict prompt).
                if let Some(cached) = find_newest_cached_artifact(project_path, &need.job_name) {
                    ready.push((need.job_name.clone(), cached));
                } else {
                    debug!("No artifacts found for needed job '{}'", need.job_name);
                }
            }
        }
    }

    Ok(ArtifactResolution { ready, conflicts })
}

/// Search all cached pipeline directories for artifacts from a specific job,
/// returning the path from the most recently modified pipeline.
fn find_newest_cached_artifact(project_path: &str, job_name: &str) -> Option<PathBuf> {
    let base = cache_dir()?
        .join(project_slug(project_path))
        .join("artifacts");

    if !base.exists() {
        return None;
    }

    let mut candidates: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();

    for entry in fs::read_dir(&base).ok()?.flatten() {
        let path = entry.path();
        if !path.is_dir() || entry.file_name() == "local" {
            continue;
        }
        let job_path = path.join(job_name);
        if job_path.exists() {
            let mtime = entry
                .metadata()
                .and_then(|m| m.modified())
                .unwrap_or(std::time::UNIX_EPOCH);
            candidates.push((job_path, mtime));
        }
    }

    candidates.sort_by_key(|entry| std::cmp::Reverse(entry.1));
    candidates.into_iter().next().map(|(path, _)| path)
}

/// Evict old pipeline artifact directories, keeping only the N most recent.
/// The `local/` directory is excluded from eviction.
pub fn evict_old_artifacts(project_path: &str, keep: usize) -> Result<()> {
    let base = cache_dir()
        .context("Cannot determine cache directory")?
        .join(project_slug(project_path))
        .join("artifacts");

    if !base.exists() {
        return Ok(());
    }

    let mut pipeline_dirs: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();

    for entry in fs::read_dir(&base)? {
        let entry = entry?;
        let path = entry.path();
        if !path.is_dir() {
            continue;
        }
        // Skip the "local" directory
        if entry.file_name() == "local" {
            continue;
        }
        let mtime = entry
            .metadata()
            .and_then(|m| m.modified())
            .unwrap_or(std::time::UNIX_EPOCH);
        pipeline_dirs.push((path, mtime));
    }

    // Sort by mtime descending (most recent first)
    pipeline_dirs.sort_by_key(|entry| std::cmp::Reverse(entry.1));

    // Remove directories beyond the keep limit
    for (path, _) in pipeline_dirs.iter().skip(keep) {
        info!("Evicting artifact cache: {}", path.display());
        if let Err(e) = fs::remove_dir_all(path) {
            warn!("Failed to remove {}: {}", path.display(), e);
        }
    }

    Ok(())
}

/// Extract the pipeline ID from a cached artifact path.
///
/// The cache layout is `.../artifacts/<pipeline_id>/<job_name>/`, so the
/// pipeline ID is the parent directory of the job directory.
fn pipeline_id_from_path(path: &std::path::Path) -> Option<&str> {
    path.parent()?.file_name()?.to_str()
}

/// Prompt the user to resolve artifact conflicts (local vs remote).
/// Returns the resolved list of (job_name, path) pairs.
pub fn resolve_conflicts(
    conflicts: &[ArtifactConflict],
    pipeline_id: Option<&str>,
) -> Vec<(String, PathBuf)> {
    let mut resolved = Vec::new();

    for conflict in conflicts {
        let pid = pipeline_id
            .or_else(|| pipeline_id_from_path(&conflict.remote_path))
            .unwrap_or("unknown");
        println!(
            "\x1b[33mArtifacts for '{}' available from both sources:\x1b[0m",
            conflict.job_name
        );
        println!("  [L] Local (from previous container run)");
        println!("  [R] Remote (from pipeline #{})", pid);

        loop {
            print!("  Choose [L/R]: ");
            let _ = io::stdout().flush();
            let mut input = String::new();
            if io::stdin().read_line(&mut input).is_err() {
                break;
            }
            match input.trim().to_uppercase().as_str() {
                "L" => {
                    resolved.push((conflict.job_name.clone(), conflict.local_path.clone()));
                    break;
                }
                "R" => {
                    resolved.push((conflict.job_name.clone(), conflict.remote_path.clone()));
                    break;
                }
                _ => {
                    println!("  Please enter L or R.");
                }
            }
        }
    }

    resolved
}

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

    #[test]
    fn test_project_slug() {
        assert_eq!(project_slug("mesa/mesa"), "mesa--mesa");
        assert_eq!(
            project_slug("group/subgroup/project"),
            "group--subgroup--project"
        );
        assert_eq!(project_slug("project"), "project");
    }

    #[test]
    fn test_pipeline_id_from_path() {
        use std::path::Path;
        let path = Path::new(
            "/home/user/.cache/cui-cui/bentiss--hid/artifacts/1644812/fedora@vm-build-x86",
        );
        assert_eq!(pipeline_id_from_path(path), Some("1644812"));

        let path = Path::new("/cache/artifacts/99999/build-job");
        assert_eq!(pipeline_id_from_path(path), Some("99999"));

        let root = Path::new("/");
        assert_eq!(pipeline_id_from_path(root), None);
    }

    #[test]
    fn test_resolve_artifacts_empty_needs() {
        let result =
            resolve_artifacts(&[], "mesa/mesa", None, None, &OutputConfig::default()).unwrap();
        assert!(result.ready.is_empty());
        assert!(result.conflicts.is_empty());
    }

    #[test]
    fn test_resolve_artifacts_no_artifacts_flag() {
        let needs = vec![ResolvedNeed {
            job_name: "build".to_string(),
            artifacts: false,
            optional: false,
        }];
        let result =
            resolve_artifacts(&needs, "mesa/mesa", None, None, &OutputConfig::default()).unwrap();
        assert!(result.ready.is_empty());
        assert!(result.conflicts.is_empty());
    }

    /// Helper: build a tar archive in memory from a list of (path, content) pairs.
    /// Uses raw header manipulation so we can create entries with unsafe paths
    /// (absolute, `..` traversal) that the tar crate normally rejects.
    fn build_tar(entries: &[(&str, &[u8])]) -> Vec<u8> {
        let mut buf = Vec::new();
        for (path, data) in entries {
            let path_bytes = path.as_bytes();
            // Build a 512-byte POSIX tar header
            let mut header = [0u8; 512];
            // Name field (0..100)
            let name_len = path_bytes.len().min(100);
            header[..name_len].copy_from_slice(&path_bytes[..name_len]);
            // Mode (100..108): "0000644\0"
            header[100..108].copy_from_slice(b"0000644\0");
            // UID (108..116): "0000000\0"
            header[108..116].copy_from_slice(b"0000000\0");
            // GID (116..124): "0000000\0"
            header[116..124].copy_from_slice(b"0000000\0");
            // Size (124..136): octal
            let size_str = format!("{:011o}\0", data.len());
            header[124..136].copy_from_slice(size_str.as_bytes());
            // Mtime (136..148): "00000000000\0"
            header[136..148].copy_from_slice(b"00000000000\0");
            // Typeflag (156): '0' = regular file
            header[156] = b'0';
            // Checksum (148..156): compute after filling other fields
            // First fill with spaces for checksum calculation
            header[148..156].copy_from_slice(b"        ");
            let cksum: u32 = header.iter().map(|&b| b as u32).sum();
            let cksum_str = format!("{:06o}\0 ", cksum);
            header[148..156].copy_from_slice(cksum_str.as_bytes());

            buf.extend_from_slice(&header);
            buf.extend_from_slice(data);
            // Pad to 512-byte boundary
            let padding = (512 - (data.len() % 512)) % 512;
            buf.extend(std::iter::repeat(0u8).take(padding));
        }
        // End-of-archive: two 512-byte zero blocks
        buf.extend(std::iter::repeat(0u8).take(1024));
        buf
    }

    #[test]
    fn test_safe_untar_normal_files() {
        let dir = tempfile::tempdir().unwrap();
        let tar_data = build_tar(&[("hello.txt", b"hello"), ("sub/nested.txt", b"nested")]);

        safe_untar(&tar_data, dir.path()).unwrap();

        assert_eq!(
            fs::read_to_string(dir.path().join("hello.txt")).unwrap(),
            "hello"
        );
        assert_eq!(
            fs::read_to_string(dir.path().join("sub/nested.txt")).unwrap(),
            "nested"
        );
    }

    #[test]
    fn test_safe_untar_skips_path_traversal() {
        let dir = tempfile::tempdir().unwrap();
        let tar_data = build_tar(&[
            ("good.txt", b"safe"),
            ("../escape.txt", b"evil"),
            ("sub/../../escape2.txt", b"evil2"),
        ]);

        safe_untar(&tar_data, dir.path()).unwrap();

        // Good file extracted
        assert!(dir.path().join("good.txt").exists());
        // Traversal entries skipped
        assert!(!dir.path().join("../escape.txt").exists());
        assert!(!dir.path().parent().unwrap().join("escape.txt").exists());
        assert!(!dir.path().parent().unwrap().join("escape2.txt").exists());
    }

    #[test]
    fn test_safe_untar_skips_absolute_paths() {
        let dir = tempfile::tempdir().unwrap();
        let tar_data = build_tar(&[("good.txt", b"safe"), ("/etc/evil.txt", b"evil")]);

        safe_untar(&tar_data, dir.path()).unwrap();

        assert!(dir.path().join("good.txt").exists());
        assert!(!dir.path().join("etc/evil.txt").exists());
    }

    #[test]
    fn test_evict_old_artifacts_empty_dir() {
        let _tmp = tempfile::tempdir().unwrap();
        // Just ensure it doesn't panic on non-existent dir
        let result = evict_old_artifacts("nonexistent/project", 5);
        assert!(result.is_ok());
    }

    #[test]
    fn test_safe_untar_deeply_nested() {
        let dir = tempfile::tempdir().unwrap();
        let tar_data = build_tar(&[("a/b/c/d/e/deep.txt", b"deep content")]);

        safe_untar(&tar_data, dir.path()).unwrap();

        assert_eq!(
            fs::read_to_string(dir.path().join("a/b/c/d/e/deep.txt")).unwrap(),
            "deep content"
        );
    }

    #[test]
    fn test_safe_untar_empty_archive() {
        let dir = tempfile::tempdir().unwrap();
        let tar_data = build_tar(&[]);

        safe_untar(&tar_data, dir.path()).unwrap();

        // No files extracted
        let entries: Vec<_> = fs::read_dir(dir.path()).unwrap().collect();
        assert!(entries.is_empty());
    }

    #[test]
    fn test_safe_untar_zero_byte_file() {
        let dir = tempfile::tempdir().unwrap();
        let tar_data = build_tar(&[("empty.txt", b"")]);

        safe_untar(&tar_data, dir.path()).unwrap();

        let content = fs::read_to_string(dir.path().join("empty.txt")).unwrap();
        assert!(content.is_empty());
    }

    #[test]
    fn test_evict_keeps_recent_and_removes_old() {
        use std::time::{Duration, SystemTime};

        let dir = tempfile::tempdir().unwrap();
        let base = dir.path().join("proj").join("artifacts");
        fs::create_dir_all(&base).unwrap();

        // Create 5 pipeline dirs with staggered mtimes
        let base_time = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
        for i in 1u64..=5 {
            let pipeline_dir = base.join(format!("pipeline_{}", i));
            fs::create_dir_all(&pipeline_dir).unwrap();
            fs::write(pipeline_dir.join("data.txt"), format!("data {}", i)).unwrap();
            // Set mtime: pipeline_1 oldest, pipeline_5 newest
            let mtime = base_time + Duration::from_secs(i * 100);
            let times = fs::FileTimes::new().set_modified(mtime);
            fs::File::open(&pipeline_dir)
                .unwrap()
                .set_times(times)
                .unwrap();
        }

        // Also create a "local" dir that should be preserved
        let local_dir = base.join("local");
        fs::create_dir_all(&local_dir).unwrap();
        fs::write(local_dir.join("data.txt"), "local data").unwrap();

        // Replicate eviction logic inline (evict_old_artifacts uses cache_dir()
        // which points to the real HOME, not our tempdir).
        let mut pipeline_dirs: Vec<(PathBuf, SystemTime)> = Vec::new();
        for entry in fs::read_dir(&base).unwrap() {
            let entry = entry.unwrap();
            let path = entry.path();
            if !path.is_dir() || entry.file_name() == "local" {
                continue;
            }
            let mtime = entry.metadata().unwrap().modified().unwrap();
            pipeline_dirs.push((path, mtime));
        }
        pipeline_dirs.sort_by_key(|entry| std::cmp::Reverse(entry.1));

        // Remove all but the 2 most recent
        for (path, _) in pipeline_dirs.iter().skip(2) {
            fs::remove_dir_all(path).unwrap();
        }

        // Verify: 2 pipeline dirs + local remain
        let remaining: Vec<_> = fs::read_dir(&base)
            .unwrap()
            .flatten()
            .filter(|e| e.path().is_dir())
            .collect();
        assert_eq!(remaining.len(), 3); // 2 kept + local

        // local/ is preserved
        assert!(local_dir.exists());

        // The 2 newest (pipeline_5, pipeline_4) survive
        assert!(base.join("pipeline_5").exists());
        assert!(base.join("pipeline_4").exists());

        // Oldest ones are gone
        assert!(!base.join("pipeline_1").exists());
        assert!(!base.join("pipeline_2").exists());
        assert!(!base.join("pipeline_3").exists());
    }

    #[test]
    fn test_resolve_artifacts_local_only() {
        let dir = tempfile::tempdir().unwrap();

        // Create a local artifact cache
        let local_base = dir
            .path()
            .join("test--proj")
            .join("artifacts")
            .join("local")
            .join("build");
        fs::create_dir_all(&local_base).unwrap();
        fs::write(local_base.join("output.bin"), "artifact").unwrap();

        // Point cache_dir to our temp dir via XDG_CACHE_HOME
        // We can't do this safely in parallel tests, so instead test resolve_artifacts
        // without a pipeline_id or gitlab_config — it won't try remote.
        let needs = vec![ResolvedNeed {
            job_name: "build".to_string(),
            artifacts: true,
            optional: false,
        }];
        // Without gitlab_config and pipeline_id, only local cache is checked.
        // Since cache_dir() uses the real HOME, this just tests the no-artifact path.
        let result =
            resolve_artifacts(&needs, "test/proj", None, None, &OutputConfig::default()).unwrap();
        // No local artifacts at the real cache path → not found
        assert!(result.ready.is_empty());
        assert!(result.conflicts.is_empty());
    }

    #[test]
    fn test_resolve_artifacts_missing_job_no_error() {
        // Needing artifacts from a job that hasn't been built yet is not an error
        let needs = vec![ResolvedNeed {
            job_name: "never_built".to_string(),
            artifacts: true,
            optional: false,
        }];
        let result =
            resolve_artifacts(&needs, "some/project", None, None, &OutputConfig::default())
                .unwrap();
        assert!(result.ready.is_empty());
        assert!(result.conflicts.is_empty());
    }

    #[test]
    fn test_resolve_artifacts_multiple_needs_mixed() {
        // Mix of artifacts: true and artifacts: false
        let needs = vec![
            ResolvedNeed {
                job_name: "build".to_string(),
                artifacts: true,
                optional: false,
            },
            ResolvedNeed {
                job_name: "lint".to_string(),
                artifacts: false,
                optional: false,
            },
            ResolvedNeed {
                job_name: "test".to_string(),
                artifacts: true,
                optional: false,
            },
        ];
        // No cache exists → nothing found, but lint is skipped entirely
        let result =
            resolve_artifacts(&needs, "some/project", None, None, &OutputConfig::default())
                .unwrap();
        assert!(result.ready.is_empty());
        assert!(result.conflicts.is_empty());
    }

    #[test]
    fn test_artifact_pipeline_dir_structure() {
        // Verify the cache path has the expected structure
        if let Some(dir) = artifact_pipeline_dir("group/project", "12345") {
            let path_str = dir.to_string_lossy();
            assert!(path_str.contains("group--project"));
            assert!(path_str.contains("artifacts"));
            assert!(path_str.contains("12345"));
        }
        // else: no HOME/XDG_CACHE_HOME set — skip test (CI environments)
    }

    #[test]
    fn test_artifact_local_dir_structure() {
        if let Some(dir) = artifact_local_dir("group/project") {
            let path_str = dir.to_string_lossy();
            assert!(path_str.contains("group--project"));
            assert!(path_str.contains("artifacts"));
            assert!(path_str.contains("local"));
        }
    }

    /// Detect container runtime for tests (podman preferred, then docker).
    fn test_runtime() -> Option<&'static str> {
        for rt in &["podman", "docker"] {
            if Command::new(rt)
                .arg("--version")
                .stdout(Stdio::null())
                .stderr(Stdio::null())
                .status()
                .is_ok()
            {
                return Some(rt);
            }
        }
        None
    }

    #[test]
    fn test_extract_artifacts_from_volume() {
        let runtime = match test_runtime() {
            Some(r) => r,
            None => return,
        };

        let git_image = crate::container::DEFAULT_GIT_IMAGE;

        // Create a volume
        let vol_name = format!("cui-cui-test-extract-{}", std::process::id());
        let _ = Command::new(runtime)
            .args(["volume", "create", &vol_name])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();

        // Ensure git image is available
        let pull_check = Command::new(runtime)
            .args(["image", "inspect", git_image])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
        if pull_check.is_err() || !pull_check.unwrap().success() {
            eprintln!("Skipping test: git image not available");
            let _ = Command::new(runtime)
                .args(["volume", "rm", &vol_name])
                .stdout(Stdio::null())
                .stderr(Stdio::null())
                .status();
            return;
        }

        // Populate the volume with some files that simulate build artifacts
        let build_dir = "/builds/project";
        let vol_mount = format!("{}:{}", vol_name, build_dir);
        let status = Command::new(runtime)
            .args([
                "run",
                "--rm",
                "--pull",
                "never",
                "--entrypoint",
                "sh",
                "-v",
                &vol_mount,
                "-w",
                build_dir,
                git_image,
                "-c",
                "mkdir -p results && echo data1 > results/output.txt && echo data2 > other.log",
            ])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .expect("failed to populate volume");
        assert!(status.success());

        // Extract only the "results" directory
        let result = extract_artifacts_from_volume(
            runtime,
            &vol_name,
            git_image,
            "test-build-job",
            "test/extract-proj",
            build_dir,
            &["results".to_string()],
            &[],
            false,
            &OutputConfig::default(),
        );

        match result {
            Ok(Some(tar_path)) => {
                // Result should be a .tar file
                assert!(
                    tar_path.extension().is_some_and(|e| e == "tar"),
                    "result should be a .tar file, got {:?}",
                    tar_path
                );
                assert!(tar_path.exists(), "tar file should exist at {:?}", tar_path);

                // Extract the tar and verify contents
                let extract_dir = tar_path.parent().unwrap().join("verify-extract");
                fs::create_dir_all(&extract_dir).unwrap();
                safe_untar(&fs::read(&tar_path).unwrap(), &extract_dir).unwrap();

                let output_file = extract_dir.join("results/output.txt");
                assert!(
                    output_file.exists(),
                    "extracted artifact should exist at {:?}",
                    output_file
                );
                let content = fs::read_to_string(&output_file).unwrap();
                assert_eq!(content.trim(), "data1");

                // "other.log" should NOT be in the tar (not in patterns)
                assert!(
                    !extract_dir.join("other.log").exists(),
                    "non-matching file should not be extracted"
                );

                // Clean up
                let _ = fs::remove_file(&tar_path);
                let _ = fs::remove_dir_all(&extract_dir);
            }
            Ok(None) => {
                panic!("expected Some(tar_path), got None");
            }
            Err(e) => {
                panic!("extract_artifacts_from_volume failed: {}", e);
            }
        }

        // Cleanup volume
        let _ = Command::new(runtime)
            .args(["volume", "rm", &vol_name])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
    }

    #[test]
    fn test_extract_artifacts_empty_patterns() {
        let result = extract_artifacts_from_volume(
            "podman",
            "nonexistent-vol",
            "docker.io/alpine/git:latest",
            "test-job",
            "test/proj",
            "/builds/project",
            &[],
            &[],
            false,
            &OutputConfig::default(),
        );
        assert!(
            matches!(result, Ok(None)),
            "empty patterns should return Ok(None)"
        );
    }

    #[test]
    fn test_tar_extract_command_skips_pull_for_container_runtime() {
        let cmd = build_tar_extract_command(
            "container",
            "my-vol",
            "/builds/proj",
            "docker.io/alpine/git:latest",
            &["results".to_string()],
            &[],
            false,
        );
        let debug = format!("{:?}", cmd);
        assert!(
            !debug.contains("--pull"),
            "container runtime should not have --pull: {}",
            debug
        );
    }

    #[test]
    fn test_tar_extract_command_includes_pull_for_podman() {
        let cmd = build_tar_extract_command(
            "podman",
            "my-vol",
            "/builds/proj",
            "docker.io/alpine/git:latest",
            &["results".to_string()],
            &[],
            false,
        );
        let debug = format!("{:?}", cmd);
        assert!(
            debug.contains("--pull"),
            "podman should have --pull: {}",
            debug
        );
    }

    #[test]
    fn test_tar_extract_command_with_exclude() {
        let cmd = build_tar_extract_command(
            "podman",
            "my-vol",
            "/builds/proj",
            "docker.io/alpine/git:latest",
            &["binaries/".to_string()],
            &[
                "binaries/**/*.o".to_string(),
                "binaries/**/*.tmp".to_string(),
            ],
            false,
        );
        let args: Vec<_> = cmd
            .get_args()
            .map(|a| a.to_string_lossy().to_string())
            .collect();
        assert!(
            args.contains(&"--exclude=binaries/**/*.o".to_string()),
            "should contain --exclude for .o: {:?}",
            args
        );
        assert!(
            args.contains(&"--exclude=binaries/**/*.tmp".to_string()),
            "should contain --exclude for .tmp: {:?}",
            args
        );
        let exclude_pos = args
            .iter()
            .position(|a| a.starts_with("--exclude="))
            .unwrap();
        let path_pos = args.iter().position(|a| a == "binaries/").unwrap();
        assert!(
            exclude_pos < path_pos,
            "--exclude args should come before path args"
        );
    }

    #[test]
    fn test_tar_extract_command_untracked() {
        let cmd = build_tar_extract_command(
            "podman",
            "my-vol",
            "/builds/proj",
            "docker.io/alpine/git:latest",
            &["results/".to_string()],
            &["*.tmp".to_string()],
            true,
        );
        let args: Vec<_> = cmd
            .get_args()
            .map(|a| a.to_string_lossy().to_string())
            .collect();
        // Untracked mode tars everything with --exclude=.git
        let script = args.iter().find(|a| a.contains("--exclude=.git"));
        assert!(
            script.is_some(),
            "untracked mode should exclude .git: {:?}",
            args
        );
        // Exclude args are still passed
        assert!(
            args.contains(&"--exclude=*.tmp".to_string()),
            "exclude args should be passed: {:?}",
            args
        );
        // Explicit paths are still passed as positional args
        assert!(
            args.contains(&"results/".to_string()),
            "explicit paths should still be passed: {:?}",
            args
        );
    }

    #[test]
    fn test_is_empty_tar_all_zeros() {
        assert!(is_empty_tar(&[0u8; EMPTY_TAR_SIZE]));
    }

    #[test]
    fn test_is_empty_tar_with_data() {
        let mut data = vec![0u8; EMPTY_TAR_SIZE];
        data[512] = 0x42;
        assert!(!is_empty_tar(&data));
    }

    #[test]
    fn test_is_empty_tar_wrong_size() {
        assert!(!is_empty_tar(&[]));
        assert!(!is_empty_tar(&[0u8; 512]));
        assert!(!is_empty_tar(&[0u8; 20480]));
    }

    #[test]
    fn test_parse_dotenv_basic() {
        let vars = parse_dotenv("BUILD_TAG=0.1\nFOO=bar\n");
        assert_eq!(vars["BUILD_TAG"], "0.1");
        assert_eq!(vars["FOO"], "bar");
        assert_eq!(vars.len(), 2);
    }

    #[test]
    fn test_parse_dotenv_skips_invalid_keys() {
        let vars = parse_dotenv("GOOD=yes\nbad-key=no\n=empty\n");
        assert_eq!(vars.len(), 1);
        assert_eq!(vars["GOOD"], "yes");
    }

    #[test]
    fn test_parse_dotenv_empty_value() {
        let vars = parse_dotenv("EMPTY=\n");
        assert_eq!(vars["EMPTY"], "");
    }

    #[test]
    fn test_parse_dotenv_skips_blank_lines() {
        let vars = parse_dotenv("\n\nA=1\n\nB=2\n\n");
        assert_eq!(vars.len(), 2);
    }

    #[test]
    fn test_parse_dotenv_value_with_equals() {
        let vars = parse_dotenv("URL=https://example.com?a=1&b=2\n");
        assert_eq!(vars["URL"], "https://example.com?a=1&b=2");
    }

    #[test]
    fn test_parse_dotenv_from_file() {
        let dir = tempfile::tempdir().unwrap();
        let cache_path = dir.path().join("test-job.dotenv");
        std::fs::write(&cache_path, "TAG=v1.0\nMODE=release\n").unwrap();

        let content = std::fs::read_to_string(&cache_path).unwrap();
        let vars = parse_dotenv(&content);
        assert_eq!(vars["TAG"], "v1.0");
        assert_eq!(vars["MODE"], "release");
    }
}
