use std::collections::HashSet;

use anyhow::{Result, bail};
use indexmap::IndexMap;
use log::warn;
use serde_yaml::Value;

/// Resolve all `extends` chains in the jobs mapping.
/// Works on the raw YAML Value level so that deep merge semantics apply correctly.
/// When `tolerant` is true, missing extends targets are warned about and skipped
/// instead of causing an error; affected jobs are tagged with `_cui_cui_unresolved`.
pub fn resolve_all_extends(jobs: &mut IndexMap<String, Value>, tolerant: bool) -> Result<()> {
    let job_names: Vec<String> = jobs.keys().cloned().collect();
    for name in &job_names {
        resolve_extends_for(name, jobs, &mut HashSet::new(), tolerant)?;
    }
    // Remove the `extends` key from all jobs after resolution
    for job in jobs.values_mut() {
        if let Value::Mapping(map) = job {
            map.remove(Value::String("extends".into()));
        }
    }
    Ok(())
}

fn resolve_extends_for(
    name: &str,
    jobs: &mut IndexMap<String, Value>,
    visited: &mut HashSet<String>,
    tolerant: bool,
) -> Result<Value> {
    if visited.contains(name) {
        bail!("Circular extends detected involving job '{}'", name);
    }

    let job = if tolerant {
        match jobs.get(name).cloned() {
            Some(j) => j,
            None => {
                warn!(
                    "Job '{}' not found (referenced via extends), skipping",
                    name
                );
                return Ok(Value::Mapping(serde_yaml::Mapping::new()));
            }
        }
    } else {
        jobs.get(name)
            .cloned()
            .ok_or_else(|| anyhow::anyhow!("Job '{}' not found (referenced via extends)", name))?
    };

    let extends = extract_extends(&job);
    if extends.is_empty() {
        return Ok(job);
    }

    visited.insert(name.to_string());

    // Resolve each parent, then merge them left-to-right into a base
    let mut base = Value::Mapping(serde_yaml::Mapping::new());
    let mut unresolved: Vec<String> = Vec::new();
    for parent_name in &extends {
        match resolve_extends_for(parent_name, jobs, visited, tolerant) {
            Ok(parent) => {
                // Check if the parent itself was unresolved (empty mapping returned)
                if tolerant && !jobs.contains_key(parent_name) {
                    unresolved.push(parent_name.clone());
                } else {
                    merge_parent_into(&mut base, &parent);
                    // Propagate unresolved markers from parent
                    if let Some(Value::Sequence(parent_unresolved)) =
                        parent.get("_cui_cui_unresolved")
                    {
                        unresolved.extend(
                            parent_unresolved
                                .iter()
                                .filter_map(|v| v.as_str().map(String::from)),
                        );
                    }
                }
            }
            Err(e) => return Err(e),
        }
    }

    // Now merge the child on top of the accumulated base
    merge_parent_into(&mut base, &job);

    // Tag unresolved extends on the job
    if !unresolved.is_empty()
        && let Value::Mapping(ref mut map) = base
    {
        map.insert(
            Value::String("_cui_cui_unresolved".into()),
            Value::Sequence(unresolved.into_iter().map(Value::String).collect()),
        );
    }

    // Store the resolved version back
    jobs.insert(name.to_string(), base.clone());
    visited.remove(name);

    Ok(base)
}

fn extract_extends(job: &Value) -> Vec<String> {
    match job.get("extends") {
        Some(Value::String(s)) => vec![s.clone()],
        Some(Value::Sequence(seq)) => seq
            .iter()
            .filter_map(|v| v.as_str().map(String::from))
            .collect(),
        _ => vec![],
    }
}

/// Merge child values on top of parent (child wins).
/// Maps are recursively merged; arrays/scalars in child replace parent entirely.
fn merge_parent_into(parent: &mut Value, child: &Value) {
    match (parent, child) {
        (Value::Mapping(parent_map), Value::Mapping(child_map)) => {
            for (key, child_val) in child_map {
                if key == &Value::String("extends".into()) {
                    continue; // skip extends key itself
                }
                if let Some(parent_val) = parent_map.get_mut(key) {
                    if parent_val.is_mapping() && child_val.is_mapping() {
                        merge_parent_into(parent_val, child_val);
                    } else {
                        *parent_val = child_val.clone();
                    }
                } else {
                    parent_map.insert(key.clone(), child_val.clone());
                }
            }
        }
        (parent, child) => {
            *parent = child.clone();
        }
    }
}

/// Apply the `default:` block to a job. For any key in `default` that the job
/// doesn't already have, copy it over. This is a shallow fallback, not a deep merge.
pub fn apply_defaults(job: &mut Value, default: &Value) {
    if let (Value::Mapping(job_map), Value::Mapping(default_map)) = (job, default) {
        for (key, default_val) in default_map {
            if !job_map.contains_key(key) {
                job_map.insert(key.clone(), default_val.clone());
            }
        }
    }
}

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

    fn yaml(s: &str) -> Value {
        serde_yaml::from_str(s).unwrap()
    }

    #[test]
    fn test_single_extends() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(
            ".base".to_string(),
            yaml("image: rust:latest\nscript: [make]"),
        );
        jobs.insert("build".to_string(), yaml("extends: .base\nstage: build"));

        resolve_all_extends(&mut jobs, false).unwrap();

        let build = &jobs["build"];
        assert_eq!(build["image"].as_str(), Some("rust:latest"));
        assert_eq!(build["stage"].as_str(), Some("build"));
        assert!(build.get("extends").is_none());
    }

    #[test]
    fn test_chain_extends() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(".a".to_string(), yaml("image: alpine"));
        jobs.insert(".b".to_string(), yaml("extends: .a\nstage: test"));
        jobs.insert("job".to_string(), yaml("extends: .b\nscript: [echo hi]"));

        resolve_all_extends(&mut jobs, false).unwrap();

        let job = &jobs["job"];
        assert_eq!(job["image"].as_str(), Some("alpine"));
        assert_eq!(job["stage"].as_str(), Some("test"));
    }

    #[test]
    fn test_multiple_parents() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(".img".to_string(), yaml("image: rust:latest"));
        jobs.insert(".policy".to_string(), yaml("retry: 2"));
        jobs.insert(
            "job".to_string(),
            yaml("extends: [.img, .policy]\nscript: [make]"),
        );

        resolve_all_extends(&mut jobs, false).unwrap();

        let job = &jobs["job"];
        assert_eq!(job["image"].as_str(), Some("rust:latest"));
        assert_eq!(job["retry"].as_u64(), Some(2));
    }

    #[test]
    fn test_circular_extends() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(".a".to_string(), yaml("extends: .b"));
        jobs.insert(".b".to_string(), yaml("extends: .a"));

        let result = resolve_all_extends(&mut jobs, false);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("Circular"));
    }

    #[test]
    fn test_child_overrides_parent() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(".base".to_string(), yaml("image: alpine\nstage: test"));
        jobs.insert(
            "job".to_string(),
            yaml("extends: .base\nimage: rust:latest\nscript: [make]"),
        );

        resolve_all_extends(&mut jobs, false).unwrap();

        let job = &jobs["job"];
        assert_eq!(job["image"].as_str(), Some("rust:latest"));
        assert_eq!(job["stage"].as_str(), Some("test"));
    }

    #[test]
    fn test_deep_merge_variables() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(".base".to_string(), yaml("variables:\n  A: '1'\n  B: '2'"));
        jobs.insert(
            "job".to_string(),
            yaml("extends: .base\nvariables:\n  B: '3'\n  C: '4'\nscript: [echo]"),
        );

        resolve_all_extends(&mut jobs, false).unwrap();

        let vars = &jobs["job"]["variables"];
        assert_eq!(vars["A"].as_str(), Some("1"));
        assert_eq!(vars["B"].as_str(), Some("3"));
        assert_eq!(vars["C"].as_str(), Some("4"));
    }

    #[test]
    fn test_extends_not_found() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert("job".to_string(), yaml("extends: .missing\nscript: [echo]"));

        let result = resolve_all_extends(&mut jobs, false);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("not found"));
    }

    #[test]
    fn test_apply_defaults_fills_missing() {
        let mut job = yaml("script: [make]");
        let default = yaml("image: alpine\ntags: [shared]");

        apply_defaults(&mut job, &default);

        assert_eq!(job["image"].as_str(), Some("alpine"));
        assert_eq!(job["script"][0].as_str(), Some("make"));
    }

    #[test]
    fn test_apply_defaults_does_not_overwrite() {
        let mut job = yaml("image: rust:latest\nscript: [make]");
        let default = yaml("image: alpine");

        apply_defaults(&mut job, &default);

        assert_eq!(job["image"].as_str(), Some("rust:latest"));
    }

    #[test]
    fn test_tolerant_missing_extends() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert("job".to_string(), yaml("extends: .missing\nscript: [echo]"));

        // Tolerant mode: should succeed and mark unresolved
        resolve_all_extends(&mut jobs, true).unwrap();
        let job = &jobs["job"];
        assert_eq!(job["script"][0].as_str(), Some("echo"));
        let unresolved = job["_cui_cui_unresolved"].as_sequence().unwrap();
        assert_eq!(unresolved.len(), 1);
        assert_eq!(unresolved[0].as_str(), Some(".missing"));
    }

    #[test]
    fn test_tolerant_partial_extends() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(".base".to_string(), yaml("image: alpine"));
        jobs.insert(
            "job".to_string(),
            yaml("extends: [.base, .missing]\nscript: [echo]"),
        );

        resolve_all_extends(&mut jobs, true).unwrap();
        let job = &jobs["job"];
        // Should still inherit from .base
        assert_eq!(job["image"].as_str(), Some("alpine"));
        assert_eq!(job["script"][0].as_str(), Some("echo"));
        // Should mark .missing as unresolved
        let unresolved = job["_cui_cui_unresolved"].as_sequence().unwrap();
        assert_eq!(unresolved.len(), 1);
        assert_eq!(unresolved[0].as_str(), Some(".missing"));
    }

    #[test]
    fn test_self_extends_is_circular() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert("job".to_string(), yaml("extends: job\nscript: [echo]"));

        let result = resolve_all_extends(&mut jobs, false);
        assert!(result.is_err());
        assert!(result.unwrap_err().to_string().contains("Circular"));
    }

    #[test]
    fn test_multi_level_unresolved_propagation() {
        // .a extends .missing (doesn't exist)
        // job extends .a → .missing should propagate to job's unresolved list
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(".a".to_string(), yaml("extends: .missing\nimage: alpine"));
        jobs.insert("job".to_string(), yaml("extends: .a\nscript: [echo]"));

        resolve_all_extends(&mut jobs, true).unwrap();
        let job = &jobs["job"];
        assert_eq!(job["image"].as_str(), Some("alpine"));
        let unresolved = job["_cui_cui_unresolved"].as_sequence().unwrap();
        assert_eq!(unresolved.len(), 1);
        assert_eq!(unresolved[0].as_str(), Some(".missing"));
    }

    #[test]
    fn test_empty_extends_list() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert("job".to_string(), yaml("extends: []\nscript: [echo]"));

        resolve_all_extends(&mut jobs, false).unwrap();
        let job = &jobs["job"];
        assert_eq!(job["script"][0].as_str(), Some("echo"));
        assert!(job.get("extends").is_none());
    }

    #[test]
    fn test_deep_nested_map_merge() {
        // Parent: a: { b: { c: 1 } }
        // Child:  a: { b: { d: 2 } }
        // Result: a: { b: { c: 1, d: 2 } }
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(
            ".base".to_string(),
            yaml("nested:\n  level1:\n    key_a: 1"),
        );
        jobs.insert(
            "job".to_string(),
            yaml("extends: .base\nnested:\n  level1:\n    key_b: 2\nscript: [echo]"),
        );

        resolve_all_extends(&mut jobs, false).unwrap();
        let job = &jobs["job"];
        assert_eq!(job["nested"]["level1"]["key_a"].as_u64(), Some(1));
        assert_eq!(job["nested"]["level1"]["key_b"].as_u64(), Some(2));
    }

    #[test]
    fn test_array_replaces_not_merges() {
        // Parent has script: [a, b], child has script: [c]
        // Arrays are replaced entirely, not merged.
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(".base".to_string(), yaml("script: [a, b]"));
        jobs.insert("job".to_string(), yaml("extends: .base\nscript: [c]"));

        resolve_all_extends(&mut jobs, false).unwrap();
        let job = &jobs["job"];
        let script = job["script"].as_sequence().unwrap();
        assert_eq!(script.len(), 1);
        assert_eq!(script[0].as_str(), Some("c"));
    }

    #[test]
    fn test_multiple_parents_merge_order() {
        // extends: [.first, .second] — .second wins over .first for conflicts
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert(".first".to_string(), yaml("image: first\nstage: build"));
        jobs.insert(".second".to_string(), yaml("image: second\nretry: 2"));
        jobs.insert(
            "job".to_string(),
            yaml("extends: [.first, .second]\nscript: [echo]"),
        );

        resolve_all_extends(&mut jobs, false).unwrap();
        let job = &jobs["job"];
        // .second overrides .first's image
        assert_eq!(job["image"].as_str(), Some("second"));
        // .first's unique key is preserved
        assert_eq!(job["stage"].as_str(), Some("build"));
        // .second's unique key is preserved
        assert_eq!(job["retry"].as_u64(), Some(2));
    }

    #[test]
    fn test_extract_extends_non_string_ignored() {
        // extends: 42 → should return empty (invalid, but no panic)
        let job = yaml("extends: 42\nscript: [echo]");
        let extends = extract_extends(&job);
        assert!(extends.is_empty());
    }

    #[test]
    fn test_no_extends_key() {
        let mut jobs: IndexMap<String, Value> = IndexMap::new();
        jobs.insert("job".to_string(), yaml("script: [echo]\nimage: alpine"));

        resolve_all_extends(&mut jobs, false).unwrap();
        let job = &jobs["job"];
        assert_eq!(job["image"].as_str(), Some("alpine"));
    }
}
