//! Typed YAML AST + pretty-printer.
//!
//! Per the ★★★ NO-format!()-for-code prime directive. Used by the
//! 5 YAML ecosystems (helm / crystal / dart / conda / racket-helper).

use std::fmt::Write;

/// Ordered YAML value. Preserves insertion order (YAML maps are
/// stable in practice though spec-wise unordered).
#[derive(Debug, Clone)]
pub enum Value {
    String(String),
    Int(i64),
    Bool(bool),
    Null,
    Array(Vec<Value>),
    Map(Vec<(String, Value)>),
}

impl Value {
    pub fn s(v: impl Into<String>) -> Self { Self::String(v.into()) }
    pub fn i(v: i64) -> Self { Self::Int(v) }
    pub fn b(v: bool) -> Self { Self::Bool(v) }
    pub fn arr(vs: impl IntoIterator<Item = Value>) -> Self {
        Self::Array(vs.into_iter().collect())
    }
    pub fn map() -> Self { Self::Map(Vec::new()) }

    pub fn insert(&mut self, k: impl Into<String>, v: Value) -> &mut Self {
        if let Self::Map(items) = self {
            let key = k.into();
            if let Some(idx) = items.iter().position(|(ek, _)| ek == &key) {
                items[idx].1 = v;
            } else {
                items.push((key, v));
            }
        }
        self
    }
}

/// Render a scalar value (used at leaf positions). YAML strings may
/// often be unquoted but we quote everything that needs escaping or
/// looks like a YAML reserved literal (true/false/null/numbers).
fn render_scalar(s: &str) -> String {
    // Quote when contains : # & * [ ] { } | > ' " or starts with `- ` or
    // matches yaml keywords / could be parsed as number.
    let needs_quote = s.is_empty()
        || s.starts_with(|c: char| c.is_ascii_whitespace())
        || s.ends_with(|c: char| c.is_ascii_whitespace())
        || s.chars().any(|c| matches!(c, ':' | '#' | '&' | '*' | '[' | ']' | '{' | '}' | '|' | '>' | '\'' | '"'))
        || matches!(s, "true" | "false" | "null" | "yes" | "no")
        || s.parse::<f64>().is_ok();
    if needs_quote {
        let escaped: String = s
            .chars()
            .flat_map(|c| match c {
                '"' => "\\\"".chars().collect::<Vec<_>>().into_iter(),
                '\\' => "\\\\".chars().collect::<Vec<_>>().into_iter(),
                c => vec![c].into_iter(),
            })
            .collect();
        let mut out = String::from("\"");
        out.push_str(&escaped);
        out.push('"');
        out
    } else {
        s.to_string()
    }
}

/// Render a YAML value at a given block-style indent depth.
fn render_at(v: &Value, indent: usize, out: &mut String) {
    match v {
        Value::String(s) => out.push_str(&render_scalar(s)),
        Value::Int(n) => { let _ = write!(out, "{n}"); }
        Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
        Value::Null => out.push_str("null"),
        Value::Array(items) if items.is_empty() => out.push_str("[]"),
        Value::Array(items) => {
            for v in items {
                out.push('\n');
                push_indent(out, indent);
                out.push_str("- ");
                match v {
                    Value::Map(_) | Value::Array(_) => render_at(v, indent + 1, out),
                    _ => render_at(v, indent, out),
                }
            }
        }
        Value::Map(items) if items.is_empty() => out.push_str("{}"),
        Value::Map(items) => {
            for (k, v) in items {
                out.push('\n');
                push_indent(out, indent);
                out.push_str(&render_scalar(k));
                out.push(':');
                match v {
                    Value::Map(inner) if !inner.is_empty() => render_at(v, indent + 1, out),
                    Value::Array(inner) if !inner.is_empty() => render_at(v, indent + 1, out),
                    _ => {
                        out.push(' ');
                        render_at(v, indent, out);
                    }
                }
            }
        }
    }
}

fn push_indent(out: &mut String, depth: usize) {
    for _ in 0..depth { out.push_str("  "); }
}

pub fn render(v: &Value) -> String {
    let mut out = String::new();
    match v {
        Value::Map(_) | Value::Array(_) => {
            // Render top-level map without leading newline
            render_at(v, 0, &mut out);
            if out.starts_with('\n') {
                out.remove(0);
            }
        }
        _ => render_at(v, 0, &mut out),
    }
    if !out.ends_with('\n') { out.push('\n'); }
    out
}

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

    #[test]
    fn scalar_with_colon_gets_quoted() {
        assert!(render_scalar("foo:bar").starts_with('"'));
        assert!(!render_scalar("plain").starts_with('"'));
    }
    #[test]
    fn map_renders_with_indent() {
        let mut m = Value::map();
        m.insert("name", Value::s("demo"));
        m.insert("version", Value::s("0.1.0"));
        let out = render(&m);
        assert!(out.contains("name: demo"));
        assert!(out.contains("version: "));
    }
    #[test]
    fn nested_map_indented() {
        let mut outer = Value::map();
        let mut inner = Value::map();
        inner.insert("type", Value::s("git"));
        outer.insert("repository", inner);
        let s = render(&outer);
        assert!(s.contains("repository:"));
        assert!(s.contains("  type: git"));
    }
}
