use std::path::Path;
use std::sync::LazyLock;

use anyhow::{Context, Result, bail};
use indexmap::IndexMap;
use log::{debug, warn};
use regex::Regex;
use serde_yaml::Value;

use crate::model::{RawJobConfig, RawPipeline, SourceLocation, VarValue, WorkflowConfig};

use super::fetch::GitlabConfig;
use crate::parser::merge::deep_merge;

/// Known top-level keywords that are NOT jobs.
const RESERVED_KEYS: &[&str] = &[
    "stages",
    "variables",
    "default",
    "workflow",
    "include",
    "image",
    "services",
    "before_script",
    "after_script",
    "cache",
];

/// Parse a `.gitlab-ci.yml` file into a `RawPipeline`.
/// If `gitlab_config` is provided, remote includes (`project:`, `remote:`, `template:`)
/// will be fetched via the GitLab API.
pub fn parse_pipeline_file(
    path: &Path,
    gitlab_config: Option<&GitlabConfig>,
) -> Result<RawPipeline> {
    parse_pipeline_file_with_root(path, gitlab_config, None)
}

/// Parse a pipeline file, resolving local includes relative to `project_root`
/// instead of the file's parent directory. This is needed for child pipelines
/// triggered via `trigger: include: local:`, where local includes must resolve
/// relative to the project root (the directory containing the root `.gitlab-ci.yml`).
pub fn parse_pipeline_file_with_root(
    path: &Path,
    gitlab_config: Option<&GitlabConfig>,
    project_root: Option<&Path>,
) -> Result<RawPipeline> {
    let raw_content = std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read {}", path.display()))?;

    let (spec_inputs, body) = extract_spec_and_content(&raw_content);
    let root_inputs = build_effective_inputs(&spec_inputs, &IndexMap::new());
    let content = if root_inputs.is_empty() {
        body.to_string()
    } else {
        interpolate_inputs(body, &root_inputs)
    };

    crate::parser::check_yaml_size(&content, &path.display().to_string())?;
    let mut root: Value = serde_yaml::from_str(&content)
        .with_context(|| format!("Failed to parse YAML in {}", path.display()))?;

    // Build source map: track which file:line each top-level key is defined in.
    // The parent file's keys take precedence over included keys (matching deep_merge).
    // For local includes, resolve relative to the project root (git repo root),
    // not the CI file's directory, since GitLab CI paths are repo-relative.
    let detected_root = if project_root.is_none() {
        crate::git_utils::find_git_root(path)
    } else {
        None
    };
    let base_dir = project_root
        .or(detected_root.as_deref())
        .unwrap_or_else(|| path.parent().unwrap_or_else(|| Path::new(".")));
    let display_path = make_display_path(path, base_dir);
    let mut source_map: IndexMap<String, SourceLocation> = IndexMap::new();
    // Scan parent file first — these win on conflicts
    for (key, line) in scan_top_level_keys(&content) {
        source_map.insert(
            key,
            SourceLocation {
                file: display_path.clone(),
                line,
            },
        );
    }

    // Process includes (local + remote)
    process_includes(&mut root, base_dir, gitlab_config, &mut source_map, None, 0)?;

    // Process !reference-like patterns (stored as tagged values or sequences).
    // Iterate until stable, since references may point to values that themselves
    // contain references (e.g. !reference [.download_s3, before_script] where
    // .download_s3.before_script contains !reference [default, before_script]).
    let mut reference_resolved = false;
    for _ in 0..10 {
        let snapshot = root.clone();
        resolve_references(&snapshot, &mut root);
        if root == snapshot {
            reference_resolved = true;
            break;
        }
    }
    if !reference_resolved {
        warn!(
            "!reference resolution did not stabilize after 10 iterations (possible circular reference)"
        );
    }

    // After !reference resolution, some sequence fields (notably `needs`) may
    // contain nested sequences, e.g. `needs: [[a, b], c]` when a !reference
    // expanded to a list.  Flatten these so deserialization into Vec<T> works.
    flatten_nested_sequences(&mut root);

    build_raw_pipeline(&root, source_map)
}

/// When processing includes inside a remotely fetched file, `local:` includes
/// must be resolved against the same remote project rather than the local filesystem.
struct RemoteContext {
    project: String,
    ref_name: String,
}

/// Parse a `spec:inputs:` header and return a map of input name → optional default.
/// `None` means the input is required (no default). Returns an empty map on failure.
fn parse_spec_inputs(spec_text: &str) -> IndexMap<String, Option<String>> {
    let parsed: Value = match serde_yaml::from_str(spec_text) {
        Ok(v) => v,
        Err(_) => return IndexMap::new(),
    };
    let inputs_mapping = match parsed.get("spec").and_then(|s| s.get("inputs")) {
        Some(Value::Mapping(m)) => m,
        _ => return IndexMap::new(),
    };
    let mut result = IndexMap::new();
    for (key, val) in inputs_mapping {
        let name = match key.as_str() {
            Some(s) => s.to_string(),
            None => continue,
        };
        let default = match val {
            Value::Null => None,
            Value::Mapping(m) => m.get(Value::String("default".into())).map(value_to_string),
            _ => None,
        };
        result.insert(name, default);
    }
    result
}

pub(crate) fn value_to_string(v: &Value) -> String {
    match v {
        Value::String(s) => s.clone(),
        Value::Number(n) => n.to_string(),
        Value::Bool(b) => b.to_string(),
        Value::Null => String::new(),
        other => format!("{:?}", other),
    }
}

/// Split a raw YAML file into its `spec:inputs:` header and body content.
fn extract_spec_and_content(raw: &str) -> (IndexMap<String, Option<String>>, &str) {
    if let Some(pos) = raw.find("\n---\n") {
        let before = raw[..pos].trim();
        if before.starts_with("spec:") {
            let inputs = parse_spec_inputs(before);
            return (inputs, &raw[pos + 5..]);
        }
    }
    (IndexMap::new(), raw)
}

/// Merge caller-provided input values with spec defaults.
/// Caller values take precedence over defaults.
fn build_effective_inputs(
    spec: &IndexMap<String, Option<String>>,
    caller: &IndexMap<String, String>,
) -> IndexMap<String, String> {
    let mut effective = IndexMap::new();
    for (name, default) in spec {
        if let Some(val) = caller.get(name) {
            effective.insert(name.clone(), val.clone());
        } else if let Some(def) = default {
            effective.insert(name.clone(), def.clone());
        } else {
            warn!("input '{}' has no default and no value provided", name);
        }
    }
    for (name, val) in caller {
        effective.entry(name.clone()).or_insert_with(|| val.clone());
    }
    effective
}

static INPUTS_PATTERN: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\$\[\[\s*inputs\.([\w-]+)\s*\]\]").unwrap());

/// Replace `$[[ inputs.NAME ]]` placeholders in raw YAML text.
fn interpolate_inputs(content: &str, values: &IndexMap<String, String>) -> String {
    let re = &*INPUTS_PATTERN;
    re.replace_all(content, |caps: &regex::Captures| {
        let name = &caps[1];
        match values.get(name) {
            Some(val) => val.clone(),
            None => {
                warn!("unresolved input placeholder $[[ inputs.{} ]]", name);
                caps[0].to_string()
            }
        }
    })
    .into_owned()
}

/// Extract `inputs:` values from an include mapping entry.
fn extract_inputs_from_include(m: &serde_yaml::Mapping) -> IndexMap<String, String> {
    match m.get(Value::String("inputs".into())) {
        Some(Value::Mapping(inputs_map)) => inputs_map
            .iter()
            .filter_map(|(k, v)| {
                let key = k.as_str()?.to_string();
                Some((key, value_to_string(v)))
            })
            .collect(),
        _ => IndexMap::new(),
    }
}

/// Maximum include recursion depth (matches GitLab's limit).
const MAX_INCLUDE_DEPTH: usize = 100;

/// Handle all forms of `include:`.
/// `base_dir` is the directory used to resolve local includes (typically the project root).
/// `remote_ctx` is set when we are inside a remotely fetched file, so that `local:` includes
/// are fetched from the same remote project.
/// `depth` tracks recursion depth to prevent infinite include loops.
fn process_includes(
    root: &mut Value,
    base_dir: &Path,
    gitlab_config: Option<&GitlabConfig>,
    source_map: &mut IndexMap<String, SourceLocation>,
    remote_ctx: Option<&RemoteContext>,
    depth: usize,
) -> Result<()> {
    if depth > MAX_INCLUDE_DEPTH {
        bail!(
            "include recursion depth exceeds {} (possible cycle)",
            MAX_INCLUDE_DEPTH
        );
    }
    let includes = match root.get("include") {
        Some(v) => v.clone(),
        None => return Ok(()),
    };
    debug!("Processing includes: {:?}", includes);

    let include_entries = normalize_includes(&includes);

    for resolved_inc in include_entries {
        let (yaml_content, inc_display_path, entry_remote_ctx) = match resolved_inc.entry {
            IncludeEntry::Local(path_str) if remote_ctx.is_some() => {
                // Inside a remotely fetched file: resolve local includes
                // against the same remote project.
                let ctx = remote_ctx.unwrap();
                let remote_ctx_for_children = Some(RemoteContext {
                    project: ctx.project.clone(),
                    ref_name: ctx.ref_name.clone(),
                });

                // Handle glob patterns via the repository tree API
                if path_str.contains('*') || path_str.contains('?') {
                    if let Some(config) = gitlab_config {
                        let trimmed = path_str.trim_start_matches('/');
                        // Extract the directory prefix (before the first glob char)
                        let dir_prefix = trimmed
                            .find(['*', '?'])
                            .and_then(|pos| trimmed[..pos].rfind('/'))
                            .map(|pos| &trimmed[..pos])
                            .unwrap_or("");
                        let pattern = glob::Pattern::new(trimmed);
                        match (
                            pattern,
                            config.list_project_tree(&ctx.project, dir_prefix, &ctx.ref_name, true),
                        ) {
                            (Ok(pat), Ok(files)) => {
                                let matched: Vec<_> =
                                    files.iter().filter(|f| pat.matches(f)).cloned().collect();
                                debug!(
                                    "remote glob '{}' in project '{}' matched {} files",
                                    path_str,
                                    ctx.project,
                                    matched.len()
                                );
                                for file_path in matched {
                                    let remote_file = format!("/{}", file_path);
                                    let display = format!("{}:{}", ctx.project, remote_file);
                                    match config.fetch_project_file(
                                        &ctx.project,
                                        &remote_file,
                                        &ctx.ref_name,
                                    ) {
                                        Ok(content) => {
                                            let (spec, body) = extract_spec_and_content(&content);
                                            let eff =
                                                build_effective_inputs(&spec, &resolved_inc.inputs);
                                            let interpolated = if eff.is_empty() {
                                                body.to_string()
                                            } else {
                                                interpolate_inputs(body, &eff)
                                            };
                                            for (key, line) in scan_top_level_keys(&interpolated) {
                                                source_map.entry(key).or_insert(SourceLocation {
                                                    file: display.clone(),
                                                    line,
                                                });
                                            }
                                            match serde_yaml::from_str::<Value>(&interpolated) {
                                                Ok(mut inc_value) => {
                                                    process_includes(
                                                        &mut inc_value,
                                                        base_dir,
                                                        gitlab_config,
                                                        source_map,
                                                        remote_ctx_for_children.as_ref(),
                                                        depth + 1,
                                                    )?;
                                                    deep_merge(root, &inc_value);
                                                }
                                                Err(e) => warn!(
                                                    "failed to parse remote include {}: {}",
                                                    display, e
                                                ),
                                            }
                                        }
                                        Err(e) => warn!(
                                            "failed to fetch remote include '{}': {}",
                                            display, e
                                        ),
                                    }
                                }
                            }
                            (Err(e), _) => {
                                warn!("invalid glob pattern '{}': {}", path_str, e);
                            }
                            (_, Err(e)) => {
                                warn!(
                                    "failed to list tree for glob '{}' in project '{}': {}",
                                    path_str, ctx.project, e
                                );
                            }
                        }
                    } else {
                        warn!(
                            "cannot resolve glob '{}' from project '{}' (no GitLab config)",
                            path_str, ctx.project
                        );
                    }
                    continue;
                }

                let file = if path_str.starts_with('/') {
                    path_str.clone()
                } else {
                    format!("/{}", path_str)
                };
                let display = format!("{}:{}", ctx.project, file);
                if let Some(config) = gitlab_config {
                    match config.fetch_project_file(&ctx.project, &file, &ctx.ref_name) {
                        Ok(content) => (Some(content), display, remote_ctx_for_children),
                        Err(e) => {
                            warn!(
                                "failed to fetch local include '{}' from project '{}': {}",
                                file, ctx.project, e
                            );
                            (None, display, None)
                        }
                    }
                } else {
                    warn!(
                        "cannot fetch local include '{}' from project '{}' (no GitLab config)",
                        file, ctx.project
                    );
                    (None, display, None)
                }
            }
            IncludeEntry::Local(path_str) => {
                let inc_path_str = path_str.trim_start_matches('/');
                // Check for glob patterns
                if inc_path_str.contains('*') || inc_path_str.contains('?') {
                    let pattern = base_dir.join(inc_path_str).to_string_lossy().to_string();
                    let mut paths: Vec<_> = glob::glob(&pattern)
                        .into_iter()
                        .flatten()
                        .filter_map(|r| r.ok())
                        .collect();
                    paths.sort();
                    for glob_path in paths {
                        // Validate path before reading: canonicalize resolves
                        // symlinks, so we can check containment. Then read the
                        // canonical path to avoid a symlink-swap race.
                        let (canonical, read_path) =
                            match (glob_path.canonicalize(), base_dir.canonicalize()) {
                                (Ok(c), Ok(cb)) if c.starts_with(&cb) => (c.clone(), c),
                                (Ok(_), Ok(_)) => {
                                    warn!(
                                        "include glob matched path outside project: {}, skipping",
                                        glob_path.display()
                                    );
                                    continue;
                                }
                                // If canonicalize fails, fall back to the
                                // original path (e.g. non-existent targets)
                                _ => (glob_path.clone(), glob_path.clone()),
                            };
                        let _ = canonical; // used for the containment check above
                        let content = std::fs::read_to_string(&read_path).with_context(|| {
                            format!("Failed to read include {}", glob_path.display())
                        })?;
                        let display = make_display_path(&glob_path, base_dir);
                        let (spec, body) = extract_spec_and_content(&content);
                        let eff = build_effective_inputs(&spec, &resolved_inc.inputs);
                        let interpolated = if eff.is_empty() {
                            body.to_string()
                        } else {
                            interpolate_inputs(body, &eff)
                        };
                        for (key, line) in scan_top_level_keys(&interpolated) {
                            source_map.entry(key).or_insert(SourceLocation {
                                file: display.clone(),
                                line,
                            });
                        }
                        match serde_yaml::from_str::<Value>(&interpolated) {
                            Ok(mut inc_value) => {
                                process_includes(
                                    &mut inc_value,
                                    base_dir,
                                    gitlab_config,
                                    source_map,
                                    None,
                                    depth + 1,
                                )?;
                                deep_merge(root, &inc_value);
                            }
                            Err(e) => warn!("failed to parse included YAML {}: {}", display, e),
                        }
                    }
                    continue;
                }
                let inc_path = base_dir.join(inc_path_str);
                // Validate path before reading: canonicalize first to
                // resolve symlinks, then read the canonical path to
                // close the symlink-swap TOCTOU window.
                if let (Ok(canonical), Ok(canonical_base)) =
                    (inc_path.canonicalize(), base_dir.canonicalize())
                {
                    if !canonical.starts_with(&canonical_base) {
                        warn!(
                            "local include '{}' escapes project directory, skipping",
                            inc_path.display()
                        );
                        continue;
                    }
                    // Read the canonical (resolved) path, not the
                    // original, so a symlink can't be swapped between
                    // validation and read.
                    if let Ok(content) = std::fs::read_to_string(&canonical) {
                        let display = make_display_path(&inc_path, base_dir);
                        (Some(content), display, None)
                    } else {
                        anyhow::bail!("local include '{}' not found", inc_path.display());
                    }
                } else if inc_path.exists() {
                    // canonicalize failed but file exists — read directly
                    // (non-symlink case where canonicalize may fail on
                    // some filesystems)
                    let content = std::fs::read_to_string(&inc_path).with_context(|| {
                        format!("Failed to read include {}", inc_path.display())
                    })?;
                    let display = make_display_path(&inc_path, base_dir);
                    (Some(content), display, None)
                } else {
                    anyhow::bail!("local include '{}' not found", inc_path.display());
                }
            }
            IncludeEntry::Project {
                project,
                file,
                ref_name,
            } => {
                let display = format!("{}:{}", project, file);
                let git_ref = ref_name.as_deref().unwrap_or("HEAD");
                if let Some(config) = gitlab_config {
                    match config.fetch_project_file(&project, &file, git_ref) {
                        Ok(content) => (
                            Some(content),
                            display,
                            Some(RemoteContext {
                                project: project.clone(),
                                ref_name: git_ref.to_string(),
                            }),
                        ),
                        Err(e) => {
                            warn!(
                                "failed to fetch include project='{}' file='{}': {}",
                                project, file, e
                            );
                            (None, display, None)
                        }
                    }
                } else {
                    warn!(
                        "cannot fetch include project='{}' file='{}' \
                         (no GitLab instance detected, use --gitlab-url or set git remote)",
                        project, file
                    );
                    (None, display, None)
                }
            }
            IncludeEntry::Remote(url) => {
                let display = url.clone();
                match super::fetch::fetch_remote_url(&url) {
                    Ok(content) => (Some(content), display, None),
                    Err(e) => {
                        warn!("failed to fetch remote include '{}': {}", url, e);
                        (None, display, None)
                    }
                }
            }
            IncludeEntry::Template(template) => {
                let display = format!("template:{}", template);
                let url = format!(
                    "https://gitlab.com/gitlab-org/gitlab/-/raw/master/lib/gitlab/ci/templates/{}",
                    template
                );
                match super::fetch::fetch_remote_url(&url) {
                    Ok(content) => (Some(content), display, None),
                    Err(e) => {
                        warn!("failed to fetch template '{}': {}", template, e);
                        (None, display, None)
                    }
                }
            }
            IncludeEntry::Component {
                fqdn,
                project,
                component_name,
                version,
            } => {
                let display = format!("{}/{}/{}@{}", fqdn, project, component_name, version);
                if let Some(config) = gitlab_config {
                    let file_path = format!("/templates/{}.yml", component_name);
                    let alt_path = format!("/templates/{}/template.yml", component_name);
                    let result = config
                        .fetch_project_file(&project, &file_path, &version)
                        .or_else(|_| config.fetch_project_file(&project, &alt_path, &version));
                    match result {
                        Ok(content) => (
                            Some(content),
                            display,
                            Some(RemoteContext {
                                project: project.clone(),
                                ref_name: version.to_string(),
                            }),
                        ),
                        Err(e) => {
                            warn!("failed to fetch component '{}': {}", display, e);
                            (None, display, None)
                        }
                    }
                } else {
                    warn!(
                        "cannot fetch component '{}' \
                         (no GitLab instance detected, use --gitlab-url or set git remote)",
                        display
                    );
                    (None, display, None)
                }
            }
        };

        if let Some(yaml_str) = yaml_content {
            let (spec, body) = extract_spec_and_content(&yaml_str);
            let effective = build_effective_inputs(&spec, &resolved_inc.inputs);
            let interpolated = if effective.is_empty() {
                body.to_string()
            } else {
                interpolate_inputs(body, &effective)
            };

            for (key, line) in scan_top_level_keys(&interpolated) {
                source_map.entry(key).or_insert(SourceLocation {
                    file: inc_display_path.clone(),
                    line,
                });
            }

            crate::parser::check_yaml_size(&interpolated, &inc_display_path)?;
            match serde_yaml::from_str::<Value>(&interpolated) {
                Ok(mut inc_value) => {
                    process_includes(
                        &mut inc_value,
                        base_dir,
                        gitlab_config,
                        source_map,
                        entry_remote_ctx.as_ref(),
                        depth + 1,
                    )?;
                    deep_merge(root, &inc_value);
                }
                Err(e) => {
                    warn!("failed to parse included YAML: {}", e);
                }
            }
        }
    }

    Ok(())
}

/// Parsed include entry types.
enum IncludeEntry {
    Local(String),
    Project {
        project: String,
        file: String,
        ref_name: Option<String>,
    },
    Remote(String),
    Template(String),
    Component {
        fqdn: String,
        project: String,
        component_name: String,
        version: String,
    },
}

struct ResolvedInclude {
    entry: IncludeEntry,
    inputs: IndexMap<String, String>,
}

impl ResolvedInclude {
    fn new(entry: IncludeEntry) -> Self {
        Self {
            entry,
            inputs: IndexMap::new(),
        }
    }

    fn with_inputs(entry: IncludeEntry, inputs: IndexMap<String, String>) -> Self {
        Self { entry, inputs }
    }
}

/// Normalize the various `include:` formats into a list of typed entries with inputs.
fn normalize_includes(value: &Value) -> Vec<ResolvedInclude> {
    match value {
        Value::String(s) => vec![ResolvedInclude::new(include_from_string(s))],
        Value::Sequence(seq) => seq.iter().flat_map(normalize_single_include).collect(),
        Value::Mapping(_) => normalize_single_include(value),
        _ => vec![],
    }
}

/// Classify a bare include string: URLs become Remote, everything else is Local.
fn include_from_string(s: &str) -> IncludeEntry {
    if s.starts_with("http://") || s.starts_with("https://") {
        IncludeEntry::Remote(s.to_string())
    } else {
        IncludeEntry::Local(s.to_string())
    }
}

fn normalize_single_include(value: &Value) -> Vec<ResolvedInclude> {
    match value {
        Value::String(s) => vec![ResolvedInclude::new(include_from_string(s))],
        Value::Mapping(m) => {
            if should_skip_include_rules(m) {
                return vec![];
            }
            let inputs = extract_inputs_from_include(m);
            // include:local
            if let Some(local) = m.get(Value::String("local".into())) {
                return vec![ResolvedInclude::with_inputs(
                    IncludeEntry::Local(local.as_str().unwrap_or_default().to_string()),
                    inputs,
                )];
            }
            // include:project — file: can be a string or a list of strings
            if let Some(project) = m.get(Value::String("project".into())) {
                let project = project.as_str().unwrap_or_default().to_string();
                let ref_name = m
                    .get(Value::String("ref".into()))
                    .and_then(|v| v.as_str())
                    .map(String::from);
                let files: Vec<String> = match m.get(Value::String("file".into())) {
                    Some(Value::String(s)) => vec![s.clone()],
                    Some(Value::Sequence(seq)) => seq
                        .iter()
                        .filter_map(|v| v.as_str().map(String::from))
                        .collect(),
                    _ => vec![String::new()],
                };
                return files
                    .into_iter()
                    .map(|file| {
                        ResolvedInclude::with_inputs(
                            IncludeEntry::Project {
                                project: project.clone(),
                                file,
                                ref_name: ref_name.clone(),
                            },
                            inputs.clone(),
                        )
                    })
                    .collect();
            }
            // include:remote
            if let Some(remote) = m.get(Value::String("remote".into())) {
                return vec![ResolvedInclude::with_inputs(
                    IncludeEntry::Remote(remote.as_str().unwrap_or_default().to_string()),
                    inputs,
                )];
            }
            // include:template
            if let Some(template) = m.get(Value::String("template".into())) {
                return vec![ResolvedInclude::with_inputs(
                    IncludeEntry::Template(template.as_str().unwrap_or_default().to_string()),
                    inputs,
                )];
            }
            // include:component
            if let Some(component) = m.get(Value::String("component".into())) {
                if let Some(addr) = component.as_str() {
                    if let Some(entry) = parse_component_address(addr) {
                        return vec![ResolvedInclude::with_inputs(entry, inputs)];
                    }
                    warn!("invalid component address: {}", addr);
                }
                return vec![];
            }
            vec![]
        }
        _ => vec![],
    }
}

/// Parse a component address like `gitlab.com/my-org/components/secret-detection@1.0`
/// into an `IncludeEntry::Component`.
fn parse_component_address(addr: &str) -> Option<IncludeEntry> {
    let (path, version) = addr.split_once('@')?;
    let segments: Vec<&str> = path.split('/').collect();
    // Need at least: fqdn / project-group / project-name / component-name
    if segments.len() < 4 {
        return None;
    }
    let fqdn = segments[0].to_string();
    let component_name = segments[segments.len() - 1].to_string();
    let project = segments[1..segments.len() - 1].join("/");
    Some(IncludeEntry::Component {
        fqdn,
        project,
        component_name,
        version: version.to_string(),
    })
}

/// Check whether an include entry's `rules` mean it should be skipped.
/// Returns `true` (skip) when every rule explicitly sets `when: never`.
/// When rules contain conditions we can't evaluate (`if`, `changes`, `exists`),
/// we optimistically include the file (return `false`).
fn should_skip_include_rules(mapping: &serde_yaml::Mapping) -> bool {
    let rules = match mapping.get(Value::String("rules".into())) {
        Some(Value::Sequence(seq)) => seq,
        _ => return false,
    };
    if rules.is_empty() {
        return false;
    }
    rules.iter().all(|rule| {
        rule.get("when")
            .and_then(|v| v.as_str())
            .is_some_and(|w| w == "never")
    })
}

/// Walk the YAML tree and resolve `!reference`-style patterns.
fn resolve_references(root: &Value, current: &mut Value) {
    match current {
        Value::Mapping(map) => {
            let keys: Vec<Value> = map.keys().cloned().collect();
            for key in keys {
                if let Some(val) = map.get_mut(&key) {
                    resolve_references(root, val);
                }
            }
        }
        Value::Sequence(seq) => {
            for item in seq.iter_mut() {
                resolve_references(root, item);
            }
        }
        Value::Tagged(tagged) => {
            if tagged.tag == "!reference"
                && let Value::Sequence(path) = &tagged.value
            {
                let resolved = lookup_path(root, path);
                if let Some(resolved) = resolved {
                    *current = resolved;
                }
            }
        }
        _ => {}
    }
}

/// Look up a value in the YAML tree given a path like `[".base_job", "script"]`.
fn lookup_path(root: &Value, path: &[Value]) -> Option<Value> {
    let mut current = root;
    for key in path {
        match (current, key) {
            (Value::Mapping(map), Value::String(k)) => {
                current = map.get(Value::String(k.clone()))?;
            }
            (Value::Sequence(seq), Value::Number(n)) => {
                let idx = n.as_u64()? as usize;
                current = seq.get(idx)?;
            }
            _ => return None,
        }
    }
    Some(current.clone())
}

/// Flatten nested sequences produced by `!reference` expansion.
///
/// When `!reference [.job, needs]` resolves to a sequence `[a, b]` and that
/// appears inside another sequence, the result is `[[a, b], c]`.  GitLab CI
/// flattens these into `[a, b, c]` so that `needs: [!reference [...], other]`
/// works.  We do the same for all sequence-typed fields in job mappings.
fn flatten_nested_sequences(root: &mut Value) {
    if let Value::Mapping(root_map) = root {
        for (_key, job_val) in root_map.iter_mut() {
            if let Value::Mapping(job_map) = job_val {
                for (_field_key, field_val) in job_map.iter_mut() {
                    flatten_sequence(field_val);
                }
            }
        }
    }
}

/// Recursively flatten a sequence so `[[[a, b], c], d]` becomes `[a, b, c, d]`.
fn flatten_sequence(val: &mut Value) {
    if let Value::Sequence(seq) = val
        && seq.iter().any(|v| v.is_sequence())
    {
        let mut flat = Vec::new();
        for mut item in seq.drain(..) {
            flatten_sequence(&mut item);
            if let Value::Sequence(inner) = item {
                flat.extend(inner);
            } else {
                flat.push(item);
            }
        }
        *seq = flat;
    }
}

/// Scan raw YAML text for top-level keys and their line numbers.
/// Top-level keys start at column 0 and are not comments.
fn scan_top_level_keys(content: &str) -> Vec<(String, usize)> {
    content
        .lines()
        .enumerate()
        .filter_map(|(line_num, line)| {
            if line.starts_with(' ') || line.starts_with('#') || line.is_empty() || line == "---" {
                return None;
            }
            let key = line.split(':').next()?.trim();
            let key = key.trim_matches('"').trim_matches('\'');
            if key.is_empty() {
                return None;
            }
            Some((key.to_string(), line_num + 1))
        })
        .collect()
}

/// Make a display-friendly path relative to the base directory.
fn make_display_path(path: &Path, base_dir: &Path) -> String {
    // Try to get a canonical base for stripping
    let abs_base = base_dir
        .canonicalize()
        .unwrap_or_else(|_| base_dir.to_path_buf());
    let abs_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
    abs_path
        .strip_prefix(&abs_base)
        .map(|p| p.to_string_lossy().to_string())
        .unwrap_or_else(|_| path.to_string_lossy().to_string())
}

/// Convert parsed YAML Value into RawPipeline.
fn build_raw_pipeline(
    root: &Value,
    source_map: IndexMap<String, SourceLocation>,
) -> Result<RawPipeline> {
    let mapping = root
        .as_mapping()
        .context("Root of .gitlab-ci.yml must be a mapping")?;

    // Stages
    let stages = match root.get("stages") {
        Some(v) => serde_yaml::from_value(v.clone()).unwrap_or_else(|_| default_stages()),
        None => default_stages(),
    };

    // Global variables
    let variables: IndexMap<String, VarValue> = match root.get("variables") {
        Some(v) => serde_yaml::from_value(v.clone()).unwrap_or_default(),
        None => IndexMap::new(),
    };

    // Default block, merged with top-level global keywords (image, services,
    // before_script, after_script, cache). Top-level keywords act as defaults
    // for all jobs, same as putting them in `default:`.
    let default: Option<RawJobConfig> = {
        const GLOBAL_DEFAULTS: &[&str] = &[
            "image",
            "services",
            "before_script",
            "after_script",
            "cache",
        ];
        let mut default_map = match root.get("default") {
            Some(v) => v
                .as_mapping()
                .cloned()
                .unwrap_or_else(serde_yaml::Mapping::new),
            None => serde_yaml::Mapping::new(),
        };
        for &key in GLOBAL_DEFAULTS {
            let yaml_key = Value::String(key.to_string());
            if !default_map.contains_key(&yaml_key)
                && let Some(v) = root.get(key)
            {
                default_map.insert(yaml_key, v.clone());
            }
        }
        if default_map.is_empty() {
            None
        } else {
            serde_yaml::from_value(Value::Mapping(default_map)).ok()
        }
    };

    // Workflow
    let workflow: Option<WorkflowConfig> = match root.get("workflow") {
        Some(v) => serde_yaml::from_value(v.clone()).ok(),
        None => None,
    };

    // Jobs: everything that's not a reserved keyword
    let mut jobs = IndexMap::new();
    for (key, value) in mapping {
        let key_str = match key.as_str() {
            Some(s) => s,
            None => continue,
        };
        if RESERVED_KEYS.contains(&key_str) {
            continue;
        }
        // Must be a mapping to be a job
        if !value.is_mapping() {
            continue;
        }
        match serde_yaml::from_value::<RawJobConfig>(value.clone()) {
            Ok(job) => {
                jobs.insert(key_str.to_string(), job);
            }
            Err(e) => {
                debug!("skipping '{}': {}", key_str, e);
            }
        }
    }

    Ok(RawPipeline {
        stages,
        variables,
        default,
        workflow,
        jobs,
        source_map,
        github_context: std::collections::HashMap::new(),
    })
}

fn default_stages() -> Vec<String> {
    vec![
        ".pre".to_string(),
        "build".to_string(),
        "test".to_string(),
        "deploy".to_string(),
        ".post".to_string(),
    ]
}

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

    /// Helper to parse a YAML string into a Value.
    fn yaml(s: &str) -> Value {
        serde_yaml::from_str(s).unwrap()
    }

    #[test]
    fn test_normalize_include_string() {
        let val = yaml("'path.yml'");
        let entries = normalize_includes(&val);
        assert_eq!(entries.len(), 1);
        assert!(matches!(&entries[0].entry, IncludeEntry::Local(s) if s == "path.yml"));
    }

    #[test]
    fn test_normalize_include_local_mapping() {
        let val = yaml("local: '/templates/ci.yml'");
        let entries = normalize_includes(&val);
        assert_eq!(entries.len(), 1);
        assert!(matches!(&entries[0].entry, IncludeEntry::Local(s) if s == "/templates/ci.yml"));
    }

    #[test]
    fn test_normalize_include_project_single_file() {
        let val = yaml(
            r#"
            project: 'group/project'
            file: '/templates/ci.yml'
            ref: 'main'
            "#,
        );
        let entries = normalize_includes(&val);
        assert_eq!(entries.len(), 1);
        match &entries[0].entry {
            IncludeEntry::Project {
                project,
                file,
                ref_name,
            } => {
                assert_eq!(project, "group/project");
                assert_eq!(file, "/templates/ci.yml");
                assert_eq!(ref_name.as_deref(), Some("main"));
            }
            _ => panic!("Expected IncludeEntry::Project"),
        }
    }

    #[test]
    fn test_normalize_include_project_multiple_files() {
        let val = yaml(
            r#"
            project: 'freedesktop/ci-templates'
            ref: 'abc123'
            file:
              - '/templates/debian.yml'
              - '/templates/fedora.yml'
              - '/templates/ci-fairy.yml'
            "#,
        );
        let entries = normalize_includes(&val);
        assert_eq!(entries.len(), 3);
        let files: Vec<&str> = entries
            .iter()
            .map(|inc| match &inc.entry {
                IncludeEntry::Project { file, .. } => file.as_str(),
                _ => panic!("Expected IncludeEntry::Project"),
            })
            .collect();
        assert_eq!(
            files,
            vec![
                "/templates/debian.yml",
                "/templates/fedora.yml",
                "/templates/ci-fairy.yml"
            ]
        );
        // All entries should share the same project and ref
        for inc in &entries {
            match &inc.entry {
                IncludeEntry::Project {
                    project, ref_name, ..
                } => {
                    assert_eq!(project, "freedesktop/ci-templates");
                    assert_eq!(ref_name.as_deref(), Some("abc123"));
                }
                _ => panic!("Expected IncludeEntry::Project"),
            }
        }
    }

    #[test]
    fn test_normalize_include_remote() {
        let val = yaml("remote: 'https://example.com/ci.yml'");
        let entries = normalize_includes(&val);
        assert_eq!(entries.len(), 1);
        assert!(
            matches!(&entries[0].entry, IncludeEntry::Remote(s) if s == "https://example.com/ci.yml")
        );
    }

    #[test]
    fn test_normalize_include_bare_url_string() {
        let val = yaml("'https://raw.githubusercontent.com/org/repo/main/ci.yml'");
        let entries = normalize_includes(&val);
        assert_eq!(entries.len(), 1);
        assert!(
            matches!(&entries[0].entry, IncludeEntry::Remote(s) if s == "https://raw.githubusercontent.com/org/repo/main/ci.yml")
        );
    }

    #[test]
    fn test_normalize_include_bare_url_in_list() {
        let val = yaml(
            r#"
            - 'https://example.com/ci.yml'
            - '/local.yml'
            "#,
        );
        let entries = normalize_includes(&val);
        assert_eq!(entries.len(), 2);
        assert!(
            matches!(&entries[0].entry, IncludeEntry::Remote(s) if s == "https://example.com/ci.yml")
        );
        assert!(matches!(&entries[1].entry, IncludeEntry::Local(s) if s == "/local.yml"));
    }

    #[test]
    fn test_normalize_include_template() {
        let val = yaml("template: 'Auto-DevOps.gitlab-ci.yml'");
        let entries = normalize_includes(&val);
        assert_eq!(entries.len(), 1);
        assert!(
            matches!(&entries[0].entry, IncludeEntry::Template(s) if s == "Auto-DevOps.gitlab-ci.yml")
        );
    }

    #[test]
    fn test_build_raw_pipeline_basic() {
        let root = yaml(
            r#"
            stages: [build, test]
            variables:
              FOO: bar
            build-job:
              stage: build
              script: [make]
            test-job:
              stage: test
              script: [make test]
            "#,
        );
        let pipeline = build_raw_pipeline(&root, IndexMap::new()).unwrap();
        assert_eq!(pipeline.stages, vec!["build", "test"]);
        assert_eq!(pipeline.variables["FOO"].value(), "bar");
        assert!(pipeline.jobs.contains_key("build-job"));
        assert!(pipeline.jobs.contains_key("test-job"));
    }

    #[test]
    fn test_build_raw_pipeline_default_stages() {
        let root = yaml(
            r#"
            job:
              script: [echo hi]
            "#,
        );
        let pipeline = build_raw_pipeline(&root, IndexMap::new()).unwrap();
        assert!(pipeline.stages.contains(&"build".to_string()));
        assert!(pipeline.stages.contains(&"test".to_string()));
        assert!(pipeline.stages.contains(&"deploy".to_string()));
    }

    #[test]
    fn test_build_raw_pipeline_hidden_jobs() {
        let root = yaml(
            r#"
            .base:
              image: alpine
            job:
              extends: .base
              script: [echo hi]
            "#,
        );
        let pipeline = build_raw_pipeline(&root, IndexMap::new()).unwrap();
        assert!(pipeline.jobs.contains_key(".base"));
        assert!(pipeline.jobs.contains_key("job"));
    }

    #[test]
    fn test_build_raw_pipeline_skips_reserved_keys() {
        let root = yaml(
            r#"
            stages: [build]
            variables:
              X: "1"
            default:
              image: alpine
            workflow:
              rules:
                - if: $CI_PIPELINE_SOURCE == "push"
            job:
              script: [echo hi]
            "#,
        );
        let pipeline = build_raw_pipeline(&root, IndexMap::new()).unwrap();
        // Only "job" should be in jobs, not stages/variables/default/workflow
        assert_eq!(pipeline.jobs.len(), 1);
        assert!(pipeline.jobs.contains_key("job"));
        assert!(pipeline.default.is_some());
        assert!(pipeline.workflow.is_some());
    }

    #[test]
    fn test_build_raw_pipeline_with_default_block() {
        let root = yaml(
            r#"
            default:
              image: rust:latest
              before_script: [rustup show]
            job:
              script: [cargo build]
            "#,
        );
        let pipeline = build_raw_pipeline(&root, IndexMap::new()).unwrap();
        let default = pipeline.default.unwrap();
        assert_eq!(default.image.unwrap().name(), "rust:latest");
    }

    #[test]
    fn test_lookup_path_mapping() {
        let root = yaml(
            r#"
            .base:
              script:
                - echo hello
                - echo world
            "#,
        );
        let path = vec![
            Value::String(".base".into()),
            Value::String("script".into()),
        ];
        let result = lookup_path(&root, &path).unwrap();
        assert!(result.is_sequence());
    }

    #[test]
    fn test_lookup_path_nested() {
        let root = yaml(
            r#"
            .vars:
              variables:
                FOO: bar
            "#,
        );
        let path = vec![
            Value::String(".vars".into()),
            Value::String("variables".into()),
            Value::String("FOO".into()),
        ];
        let result = lookup_path(&root, &path).unwrap();
        assert_eq!(result.as_str(), Some("bar"));
    }

    #[test]
    fn test_lookup_path_missing() {
        let root = yaml("job:\n  script: [echo]");
        let path = vec![
            Value::String("nonexistent".into()),
            Value::String("key".into()),
        ];
        assert!(lookup_path(&root, &path).is_none());
    }

    #[test]
    fn test_resolve_references_replaces_tagged() {
        let root = yaml(
            r#"
            .base:
              script:
                - echo hello
            job:
              script: !reference [.base, script]
            "#,
        );
        let mut doc = root.clone();
        resolve_references(&root, &mut doc);
        let job_script = &doc["job"]["script"];
        assert!(job_script.is_sequence());
        assert_eq!(job_script[0].as_str(), Some("echo hello"));
    }

    #[test]
    fn test_scan_top_level_keys() {
        let content = r#"stages:
  - build
  - test

variables:
  FOO: bar

.base-job:
  image: alpine

build-job:
  extends: .base-job
  script: [make]
"#;
        let keys = scan_top_level_keys(content);
        assert_eq!(
            keys,
            vec![
                ("stages".to_string(), 1),
                ("variables".to_string(), 5),
                (".base-job".to_string(), 8),
                ("build-job".to_string(), 11),
            ]
        );
    }

    #[test]
    fn test_scan_top_level_keys_ignores_comments_and_blanks() {
        let content = "# comment\n\njob:\n  script: echo\n---\n";
        let keys = scan_top_level_keys(content);
        assert_eq!(keys, vec![("job".to_string(), 3)]);
    }

    #[test]
    fn test_normalize_include_list_mixed() {
        let val = yaml(
            r#"
            - local: '/local.yml'
            - project: 'group/proj'
              file:
                - '/a.yml'
                - '/b.yml'
            - remote: 'https://example.com/c.yml'
            "#,
        );
        let entries = normalize_includes(&val);
        assert_eq!(entries.len(), 4);
        assert!(matches!(&entries[0].entry, IncludeEntry::Local(s) if s == "/local.yml"));
        assert!(
            matches!(&entries[1].entry, IncludeEntry::Project { file, .. } if file == "/a.yml")
        );
        assert!(
            matches!(&entries[2].entry, IncludeEntry::Project { file, .. } if file == "/b.yml")
        );
        assert!(
            matches!(&entries[3].entry, IncludeEntry::Remote(s) if s == "https://example.com/c.yml")
        );
    }

    #[test]
    fn test_flatten_nested_sequences() {
        // Simulates what happens when !reference expands a needs entry to a list:
        // needs: [[a, b], c] should become needs: [a, b, c]
        let mut root = yaml(
            r#"
            .base:
              needs:
                - - job_a
                  - job_b
                - job_c
            "#,
        );
        flatten_nested_sequences(&mut root);
        let needs = root[".base"]["needs"].as_sequence().unwrap();
        assert_eq!(needs.len(), 3);
        assert_eq!(needs[0].as_str(), Some("job_a"));
        assert_eq!(needs[1].as_str(), Some("job_b"));
        assert_eq!(needs[2].as_str(), Some("job_c"));
    }

    #[test]
    fn test_flatten_deeply_nested_sequences() {
        // Simulates chained !reference: a references b's needs (already a nested
        // list), producing [[[a, b], c], d] which must flatten to [a, b, c, d].
        let mut root = yaml(
            r#"
            .child:
              needs:
                - - - job_a
                    - job_b
                  - job_c
                - job_d
            "#,
        );
        flatten_nested_sequences(&mut root);
        let needs = root[".child"]["needs"].as_sequence().unwrap();
        assert_eq!(needs.len(), 4);
        assert_eq!(needs[0].as_str(), Some("job_a"));
        assert_eq!(needs[1].as_str(), Some("job_b"));
        assert_eq!(needs[2].as_str(), Some("job_c"));
        assert_eq!(needs[3].as_str(), Some("job_d"));
    }

    #[test]
    fn test_flatten_no_nesting() {
        // Already flat — should be unchanged
        let mut root = yaml(
            r#"
            job:
              needs:
                - job_a
                - job_b
            "#,
        );
        flatten_nested_sequences(&mut root);
        let needs = root["job"]["needs"].as_sequence().unwrap();
        assert_eq!(needs.len(), 2);
        assert_eq!(needs[0].as_str(), Some("job_a"));
        assert_eq!(needs[1].as_str(), Some("job_b"));
    }

    #[test]
    fn test_resolve_references_circular_stays_tagged() {
        // .a.script references .b.script, .b.script references .a.script
        // After one pass they swap and become self-references, then stabilize
        // as unresolved tagged values (not valid scripts, but no infinite loop)
        let mut doc = yaml(
            r#"
            .a:
              script: !reference [.b, script]
            .b:
              script: !reference [.a, script]
            "#,
        );
        for _ in 0..10 {
            let snapshot = doc.clone();
            resolve_references(&snapshot, &mut doc);
            if doc == snapshot {
                break;
            }
        }
        // Both end up as self-referencing tagged values (unresolved)
        let a_script = &doc[".a"]["script"];
        let b_script = &doc[".b"]["script"];
        assert!(
            matches!(a_script, Value::Tagged(_)),
            ".a.script should remain as tagged (unresolved)"
        );
        assert!(
            matches!(b_script, Value::Tagged(_)),
            ".b.script should remain as tagged (unresolved)"
        );
    }

    #[test]
    fn test_process_includes_depth_limit_direct() {
        // Call process_includes at max depth — should fail
        let mut root = yaml("include: 'nonexistent.yml'");
        let dir = tempfile::tempdir().unwrap();
        let mut source_map = IndexMap::new();
        let result = process_includes(
            &mut root,
            dir.path(),
            None,
            &mut source_map,
            None,
            MAX_INCLUDE_DEPTH + 1,
        );
        assert!(result.is_err());
        assert!(
            result
                .unwrap_err()
                .to_string()
                .contains("include recursion depth")
        );
    }

    #[test]
    fn test_process_includes_depth_limit_cycle() {
        // Create two files that include each other
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(
            dir.path().join("a.yml"),
            "include: 'b.yml'\njob_a:\n  script: [echo a]",
        )
        .unwrap();
        std::fs::write(
            dir.path().join("b.yml"),
            "include: 'a.yml'\njob_b:\n  script: [echo b]",
        )
        .unwrap();

        let mut root: Value =
            serde_yaml::from_str("include: 'a.yml'\njob:\n  script: [echo]").unwrap();
        let mut source_map = IndexMap::new();
        let result = process_includes(&mut root, dir.path(), None, &mut source_map, None, 0);
        assert!(result.is_err());
        let err_msg = result.unwrap_err().to_string();
        assert!(
            err_msg.contains("include recursion depth"),
            "expected depth error, got: {}",
            err_msg
        );
    }

    #[test]
    fn test_process_includes_within_limit() {
        // Chain of 3 includes (well within limit) — should succeed
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("c.yml"), "job_c:\n  script: [echo c]").unwrap();
        std::fs::write(
            dir.path().join("b.yml"),
            "include: 'c.yml'\njob_b:\n  script: [echo b]",
        )
        .unwrap();
        std::fs::write(
            dir.path().join("a.yml"),
            "include: 'b.yml'\njob_a:\n  script: [echo a]",
        )
        .unwrap();

        let mut root: Value = serde_yaml::from_str("include: 'a.yml'").unwrap();
        let mut source_map = IndexMap::new();
        let result = process_includes(&mut root, dir.path(), None, &mut source_map, None, 0);
        assert!(result.is_ok());
        // All jobs should be merged in
        assert!(root.get("job_a").is_some());
        assert!(root.get("job_b").is_some());
        assert!(root.get("job_c").is_some());
    }

    #[test]
    fn test_local_include_symlink_outside_project_rejected() {
        // Create project dir with a symlink pointing outside
        let project_dir = tempfile::tempdir().unwrap();
        let outside_dir = tempfile::tempdir().unwrap();

        // Write a secret file outside the project
        std::fs::write(
            outside_dir.path().join("secret.yml"),
            "secret_job:\n  script: [echo stolen]",
        )
        .unwrap();

        // Create a symlink inside the project pointing to the outside file
        std::os::unix::fs::symlink(
            outside_dir.path().join("secret.yml"),
            project_dir.path().join("evil.yml"),
        )
        .unwrap();

        let mut root: Value =
            serde_yaml::from_str("include: 'evil.yml'\njob:\n  script: [echo hi]").unwrap();
        let mut source_map = IndexMap::new();
        let result = process_includes(
            &mut root,
            project_dir.path(),
            None,
            &mut source_map,
            None,
            0,
        );
        // Should succeed (warning + skip), but the secret job must NOT be merged
        assert!(result.is_ok());
        assert!(
            root.get("secret_job").is_none(),
            "symlink outside project should be rejected"
        );
    }

    #[test]
    fn test_local_include_symlink_inside_project_accepted() {
        // Symlinks within the project should work fine
        let project_dir = tempfile::tempdir().unwrap();

        std::fs::write(
            project_dir.path().join("real.yml"),
            "linked_job:\n  script: [echo linked]",
        )
        .unwrap();

        std::os::unix::fs::symlink(
            project_dir.path().join("real.yml"),
            project_dir.path().join("link.yml"),
        )
        .unwrap();

        let mut root: Value = serde_yaml::from_str("include: 'link.yml'").unwrap();
        let mut source_map = IndexMap::new();
        let result = process_includes(
            &mut root,
            project_dir.path(),
            None,
            &mut source_map,
            None,
            0,
        );
        assert!(result.is_ok());
        assert!(
            root.get("linked_job").is_some(),
            "symlink within project should be accepted"
        );
    }

    #[test]
    fn test_local_include_dotdot_path_rejected() {
        // include: '../outside/secret.yml' should be rejected
        let parent_dir = tempfile::tempdir().unwrap();
        let project_dir = parent_dir.path().join("project");
        std::fs::create_dir_all(&project_dir).unwrap();

        // Write a file outside the project dir
        std::fs::write(
            parent_dir.path().join("secret.yml"),
            "secret_job:\n  script: [echo stolen]",
        )
        .unwrap();

        let mut root: Value =
            serde_yaml::from_str("include: '../secret.yml'\njob:\n  script: [echo hi]").unwrap();
        let mut source_map = IndexMap::new();
        let result = process_includes(&mut root, &project_dir, None, &mut source_map, None, 0);
        assert!(result.is_ok());
        assert!(
            root.get("secret_job").is_none(),
            "../ path traversal should be rejected"
        );
    }

    #[test]
    fn test_local_include_glob_symlink_outside_rejected() {
        // Glob matching a symlink that points outside should be skipped
        let project_dir = tempfile::tempdir().unwrap();
        let outside_dir = tempfile::tempdir().unwrap();

        std::fs::write(
            outside_dir.path().join("evil.yml"),
            "evil_job:\n  script: [echo evil]",
        )
        .unwrap();

        // Create ci/ subdir with a symlink
        let ci_dir = project_dir.path().join("ci");
        std::fs::create_dir_all(&ci_dir).unwrap();
        std::os::unix::fs::symlink(outside_dir.path().join("evil.yml"), ci_dir.join("evil.yml"))
            .unwrap();

        // Also add a legitimate file
        std::fs::write(ci_dir.join("good.yml"), "good_job:\n  script: [echo good]").unwrap();

        let mut root: Value = serde_yaml::from_str("include: 'ci/*.yml'").unwrap();
        let mut source_map = IndexMap::new();
        let result = process_includes(
            &mut root,
            project_dir.path(),
            None,
            &mut source_map,
            None,
            0,
        );
        assert!(result.is_ok());
        // Good file should be included
        assert!(
            root.get("good_job").is_some(),
            "legitimate glob match should be included"
        );
        // Symlink outside project should be rejected
        assert!(
            root.get("evil_job").is_none(),
            "glob symlink outside project should be rejected"
        );
    }

    #[test]
    fn test_local_include_reads_canonical_path() {
        // Verify that after validation, we read the canonical (resolved) path.
        // Create a symlink to a file inside the project — the content should
        // come from the resolved target, proving we read the canonical path.
        let project_dir = tempfile::tempdir().unwrap();
        let sub_dir = project_dir.path().join("sub");
        std::fs::create_dir_all(&sub_dir).unwrap();

        std::fs::write(
            sub_dir.join("target.yml"),
            "target_job:\n  script: [echo from target]",
        )
        .unwrap();

        std::os::unix::fs::symlink(
            sub_dir.join("target.yml"),
            project_dir.path().join("link.yml"),
        )
        .unwrap();

        let mut root: Value = serde_yaml::from_str("include: 'link.yml'").unwrap();
        let mut source_map = IndexMap::new();
        let result = process_includes(
            &mut root,
            project_dir.path(),
            None,
            &mut source_map,
            None,
            0,
        );
        assert!(result.is_ok());
        assert!(root.get("target_job").is_some());
    }

    #[test]
    fn test_resolve_references_self_reference_is_noop() {
        // .a.script references itself — lookup returns the tagged value,
        // which is the same as what's already there, so nothing changes
        let mut doc = yaml(
            r#"
            .a:
              script: !reference [.a, script]
            "#,
        );
        let snapshot = doc.clone();
        resolve_references(&snapshot, &mut doc);
        // Self-reference resolves to the tagged value itself — no change
        assert_eq!(doc, snapshot);
    }

    #[test]
    fn test_include_rules_all_never_skipped() {
        let entry = yaml(
            r#"
            local: skipped.yml
            rules:
              - when: never
            "#,
        );
        let entries = normalize_single_include(&entry);
        assert!(
            entries.is_empty(),
            "include with all rules when:never should be skipped"
        );
    }

    #[test]
    fn test_include_rules_not_all_never_kept() {
        let entry = yaml(
            r#"
            local: kept.yml
            rules:
              - if: $CI == "true"
              - when: never
            "#,
        );
        let entries = normalize_single_include(&entry);
        assert_eq!(
            entries.len(),
            1,
            "include with non-never rules should be kept"
        );
    }

    #[test]
    fn test_include_rules_with_condition_kept() {
        let entry = yaml(
            r#"
            local: conditional.yml
            rules:
              - if: $INCLUDE_BUILDS == "true"
            "#,
        );
        let entries = normalize_single_include(&entry);
        assert_eq!(
            entries.len(),
            1,
            "include with if condition should be kept optimistically"
        );
    }

    #[test]
    fn test_include_no_rules_kept() {
        let entry = yaml(
            r#"
            local: always.yml
            "#,
        );
        let entries = normalize_single_include(&entry);
        assert_eq!(
            entries.len(),
            1,
            "include without rules should always be kept"
        );
    }

    #[test]
    fn test_include_rules_empty_kept() {
        let entry = yaml(
            r#"
            local: empty_rules.yml
            rules: []
            "#,
        );
        let entries = normalize_single_include(&entry);
        assert_eq!(entries.len(), 1, "include with empty rules should be kept");
    }

    #[test]
    fn test_parse_spec_inputs_with_defaults() {
        let inputs = parse_spec_inputs(
            r#"
            spec:
              inputs:
                website:
                user:
                  default: 'test-user'
                flags:
                  default: ''
            "#,
        );
        assert_eq!(inputs.len(), 3);
        assert_eq!(inputs["website"], None);
        assert_eq!(inputs["user"], Some("test-user".to_string()));
        assert_eq!(inputs["flags"], Some(String::new()));
    }

    #[test]
    fn test_parse_spec_inputs_empty() {
        assert!(parse_spec_inputs("").is_empty());
        assert!(parse_spec_inputs("not yaml: [[[").is_empty());
        assert!(parse_spec_inputs("spec:\n  other: stuff").is_empty());
    }

    #[test]
    fn test_extract_spec_and_content() {
        let raw = "spec:\n  inputs:\n    env:\n      default: prod\n---\njob:\n  script: echo hi\n";
        let (spec, body) = extract_spec_and_content(raw);
        assert_eq!(spec.len(), 1);
        assert_eq!(spec["env"], Some("prod".to_string()));
        assert!(body.contains("job:"));
        assert!(!body.contains("spec:"));
    }

    #[test]
    fn test_extract_spec_and_content_no_spec() {
        let raw = "job:\n  script: echo hi\n";
        let (spec, body) = extract_spec_and_content(raw);
        assert!(spec.is_empty());
        assert_eq!(body, raw);
    }

    #[test]
    fn test_interpolate_inputs_basic() {
        let mut values = IndexMap::new();
        values.insert("env".to_string(), "staging".to_string());
        values.insert("job-stage".to_string(), "deploy".to_string());

        let input = "stage: $[[ inputs.job-stage ]]\nscript: deploy $[[ inputs.env ]]";
        let result = interpolate_inputs(input, &values);
        assert_eq!(result, "stage: deploy\nscript: deploy staging");
    }

    #[test]
    fn test_interpolate_inputs_whitespace_variants() {
        let mut values = IndexMap::new();
        values.insert("foo".to_string(), "bar".to_string());

        assert_eq!(interpolate_inputs("$[[inputs.foo]]", &values), "bar");
        assert_eq!(interpolate_inputs("$[[ inputs.foo ]]", &values), "bar");
        assert_eq!(interpolate_inputs("$[[  inputs.foo  ]]", &values), "bar");
    }

    #[test]
    fn test_interpolate_inputs_unresolved() {
        let values = IndexMap::new();
        let input = "$[[ inputs.missing ]]";
        assert_eq!(interpolate_inputs(input, &values), input);
    }

    #[test]
    fn test_build_effective_inputs_caller_wins() {
        let mut spec = IndexMap::new();
        spec.insert("env".to_string(), Some("prod".to_string()));
        let mut caller = IndexMap::new();
        caller.insert("env".to_string(), "staging".to_string());

        let effective = build_effective_inputs(&spec, &caller);
        assert_eq!(effective["env"], "staging");
    }

    #[test]
    fn test_build_effective_inputs_default_fallback() {
        let mut spec = IndexMap::new();
        spec.insert("env".to_string(), Some("prod".to_string()));
        spec.insert("required".to_string(), None);
        let caller = IndexMap::new();

        let effective = build_effective_inputs(&spec, &caller);
        assert_eq!(effective["env"], "prod");
        assert!(!effective.contains_key("required"));
    }

    #[test]
    fn test_extract_inputs_from_include() {
        let entry = yaml(
            r#"
            local: file.yml
            inputs:
              website: "My site"
              count: 42
              flag: true
            "#,
        );
        let inputs = extract_inputs_from_include(entry.as_mapping().unwrap());
        assert_eq!(inputs["website"], "My site");
        assert_eq!(inputs["count"], "42");
        assert_eq!(inputs["flag"], "true");
    }

    #[test]
    fn test_parse_component_address() {
        let entry = parse_component_address("gitlab.com/my-org/components/secret-detection@1.0");
        assert!(entry.is_some());
        match entry.unwrap() {
            IncludeEntry::Component {
                fqdn,
                project,
                component_name,
                version,
            } => {
                assert_eq!(fqdn, "gitlab.com");
                assert_eq!(project, "my-org/components");
                assert_eq!(component_name, "secret-detection");
                assert_eq!(version, "1.0");
            }
            _ => panic!("expected Component variant"),
        }
    }

    #[test]
    fn test_parse_component_address_deep_project() {
        let entry = parse_component_address("gitlab.example.com/group/sub/project/my-comp@main");
        match entry.unwrap() {
            IncludeEntry::Component {
                fqdn,
                project,
                component_name,
                version,
            } => {
                assert_eq!(fqdn, "gitlab.example.com");
                assert_eq!(project, "group/sub/project");
                assert_eq!(component_name, "my-comp");
                assert_eq!(version, "main");
            }
            _ => panic!("expected Component variant"),
        }
    }

    #[test]
    fn test_parse_component_address_invalid_no_version() {
        assert!(parse_component_address("gitlab.com/org/project/comp").is_none());
    }

    #[test]
    fn test_parse_component_address_invalid_too_short() {
        assert!(parse_component_address("gitlab.com/comp@1.0").is_none());
    }

    #[test]
    fn test_normalize_single_include_component() {
        let entry = yaml(
            r#"
            component: "gitlab.com/my-org/components/secret-detection@1.0"
            "#,
        );
        let results = normalize_single_include(&entry);
        assert_eq!(results.len(), 1);
        match &results[0].entry {
            IncludeEntry::Component {
                fqdn,
                project,
                component_name,
                version,
            } => {
                assert_eq!(fqdn, "gitlab.com");
                assert_eq!(project, "my-org/components");
                assert_eq!(component_name, "secret-detection");
                assert_eq!(version, "1.0");
            }
            _ => panic!("expected Component variant"),
        }
    }

    #[test]
    fn test_normalize_single_include_component_with_inputs() {
        let entry = yaml(
            r#"
            component: "gitlab.com/my-org/components/deploy@2.0"
            inputs:
              environment: staging
              replicas: 3
            "#,
        );
        let results = normalize_single_include(&entry);
        assert_eq!(results.len(), 1);
        match &results[0].entry {
            IncludeEntry::Component { component_name, .. } => {
                assert_eq!(component_name, "deploy");
            }
            _ => panic!("expected Component variant"),
        }
        assert_eq!(results[0].inputs["environment"], "staging");
        assert_eq!(results[0].inputs["replicas"], "3");
    }

    #[test]
    fn test_normalize_single_include_component_invalid() {
        let entry = yaml(
            r#"
            component: "invalid@1.0"
            "#,
        );
        let results = normalize_single_include(&entry);
        assert!(results.is_empty());
    }

    #[test]
    fn test_normalize_include_project_multiple_files_with_inputs() {
        let val = yaml(
            r#"
            project: 'my-group/my-project'
            ref: 'v1.0'
            file:
              - '/templates/a.yml'
              - '/templates/b.yml'
            inputs:
              env: prod
            "#,
        );
        let entries = normalize_single_include(&val);
        assert_eq!(entries.len(), 2);
        for inc in &entries {
            assert_eq!(inc.inputs["env"], "prod");
        }
    }

    #[test]
    fn test_value_to_string_variants() {
        assert_eq!(value_to_string(&Value::String("hello".into())), "hello");
        assert_eq!(
            value_to_string(&Value::Number(serde_yaml::Number::from(42))),
            "42"
        );
        assert_eq!(value_to_string(&Value::Bool(true)), "true");
        assert_eq!(value_to_string(&Value::Null), "");
    }

    #[test]
    fn test_parse_spec_inputs_numeric_default() {
        let spec = parse_spec_inputs(
            "spec:\n  inputs:\n    replicas:\n      default: 3\n    name:\n      default: app",
        );
        assert_eq!(spec["replicas"], Some("3".to_string()));
        assert_eq!(spec["name"], Some("app".to_string()));
    }

    #[test]
    fn test_parse_spec_inputs_boolean_default() {
        let spec = parse_spec_inputs("spec:\n  inputs:\n    verbose:\n      default: true");
        assert_eq!(spec["verbose"], Some("true".to_string()));
    }

    #[test]
    fn test_build_effective_inputs_extra_caller_keys() {
        let mut spec = IndexMap::new();
        spec.insert("env".to_string(), Some("prod".to_string()));
        let mut caller = IndexMap::new();
        caller.insert("env".to_string(), "staging".to_string());
        caller.insert("extra".to_string(), "value".to_string());

        let effective = build_effective_inputs(&spec, &caller);
        assert_eq!(effective["env"], "staging");
        assert_eq!(effective["extra"], "value");
    }
}
