//! Reference resolution and `when:` guard evaluation.
//!
//! Pipeline args carry `${...}` references that point at three namespaces:
//! `${step_id.field.path}` for an earlier step's output, `${env.VAR}` for the
//! process environment, and `${var.X}` for a CLI-supplied `--var X=value`.
//! This module turns those references into concrete JSON values before a step
//! calls its tool, and evaluates the `when:` guard that decides whether a
//! step runs at all.
//!
//! Why a hand-rolled resolver and not the config crate's interpolator: the
//! config interpolator only substitutes into strings against a flat string
//! map. Pipeline references resolve into structured JSON (a `${find.users}`
//! reference can yield an array) and index into prior-step output by
//! JSONPath, which the flat interpolator cannot express.

use std::collections::HashMap;

use serde_json::Value;
use thiserror::Error;

use super::jsonpath;

/// The values a reference can resolve against during a pipeline run.
///
/// `outputs` accumulates each completed step's result keyed by step id;
/// `vars` holds `--var` pairs; environment lookups go straight to the
/// process. Held by reference so the executor owns the growing map.
pub struct ResolveContext<'a> {
    /// Completed step outputs, keyed by step id.
    pub outputs: &'a HashMap<String, Value>,
    /// CLI `--var KEY=VALUE` pairs.
    pub vars: &'a HashMap<String, String>,
}

/// Errors raised while resolving a reference or evaluating a guard.
#[derive(Debug, Error)]
pub enum ReferenceError {
    /// A `${var.X}` reference named an undefined CLI variable.
    #[error("reference `${{{0}}}` is undefined (pass it with --var)")]
    UndefinedVar(String),
    /// A `${env.X}` reference named an unset environment variable.
    #[error("reference `${{env.{0}}}` is not set in the environment")]
    UnsetEnv(String),
    /// A `${step.field}` reference named a step that has not run yet.
    #[error("reference `${{{0}}}` points at step `{1}` which has no output yet")]
    UnknownStep(String, String),
    /// The JSONPath inside a reference or guard failed.
    #[error(transparent)]
    Path(#[from] jsonpath::JsonPathError),
    /// The reference body was empty (`${}`).
    #[error("empty reference `${{}}`")]
    Empty,
}

/// Recursively resolve every `${...}` reference inside an args value.
///
/// Strings are scanned for references; objects and arrays recurse. A string
/// that is exactly one reference (`"${find.users}"`) is replaced by the
/// referenced JSON value preserving its type (so an array stays an array).
/// A string with surrounding text (`"Hi ${details.name}"`) interpolates the
/// reference as text.
pub fn resolve_args(value: &Value, ctx: &ResolveContext) -> Result<Value, ReferenceError> {
    match value {
        Value::String(s) => resolve_string(s, ctx),
        Value::Array(items) => {
            let mut out = Vec::with_capacity(items.len());
            for item in items {
                out.push(resolve_args(item, ctx)?);
            }
            Ok(Value::Array(out))
        }
        Value::Object(map) => {
            let mut out = serde_json::Map::with_capacity(map.len());
            for (k, v) in map {
                out.insert(k.clone(), resolve_args(v, ctx)?);
            }
            Ok(Value::Object(out))
        }
        other => Ok(other.clone()),
    }
}

fn resolve_string(s: &str, ctx: &ResolveContext) -> Result<Value, ReferenceError> {
    let refs = find_references(s);
    if refs.is_empty() {
        return Ok(Value::String(s.to_string()));
    }
    // Whole-string single reference: preserve the JSON type.
    if refs.len() == 1 && refs[0].0 == 0 && refs[0].1 == s.len() {
        return resolve_reference(&refs[0].2, ctx);
    }
    // Mixed text and references: interpolate as text.
    let mut out = String::new();
    let mut cursor = 0usize;
    for (start, end, body) in refs {
        out.push_str(&s[cursor..start]);
        let value = resolve_reference(&body, ctx)?;
        out.push_str(&value_to_text(&value));
        cursor = end;
    }
    out.push_str(&s[cursor..]);
    Ok(Value::String(out))
}

/// Find every `${...}` span in a string, returning (start, end, body).
///
/// `end` is the index past the closing brace. Bodies are returned trimmed.
fn find_references(s: &str) -> Vec<(usize, usize, String)> {
    let bytes = s.as_bytes();
    let mut refs = Vec::new();
    let mut i = 0usize;
    while i + 1 < bytes.len() {
        if bytes[i] == b'$' && bytes[i + 1] == b'{' {
            if let Some(close) = s[i + 2..].find('}') {
                let end = i + 2 + close + 1;
                let body = s[i + 2..i + 2 + close].trim().to_string();
                refs.push((i, end, body));
                i = end;
                continue;
            }
        }
        i += 1;
    }
    refs
}

fn resolve_reference(body: &str, ctx: &ResolveContext) -> Result<Value, ReferenceError> {
    if body.is_empty() {
        return Err(ReferenceError::Empty);
    }
    let (head, rest) = split_head(body);
    match head {
        "env" => {
            let name = rest;
            std::env::var(name)
                .map(Value::String)
                .map_err(|_| ReferenceError::UnsetEnv(name.to_string()))
        }
        "var" => ctx
            .vars
            .get(rest)
            .map(|v| Value::String(v.clone()))
            .ok_or_else(|| ReferenceError::UndefinedVar(rest.to_string())),
        step_id => {
            let output = ctx.outputs.get(step_id).ok_or_else(|| {
                ReferenceError::UnknownStep(body.to_string(), step_id.to_string())
            })?;
            if rest.is_empty() {
                Ok(output.clone())
            } else {
                // Build a JSONPath rooted at the step output. `users[0].id`
                // becomes `$.users[0].id`.
                let path = format!("$.{rest}");
                Ok(jsonpath::extract_one(output, &path)?)
            }
        }
    }
}

/// Split `env.NAME`, `var.X`, or `step.a.b.c` into the leading segment and
/// the remainder after the first dot.
fn split_head(body: &str) -> (&str, &str) {
    match body.split_once('.') {
        Some((head, rest)) => (head, rest),
        None => (body, ""),
    }
}

fn value_to_text(value: &Value) -> String {
    match value {
        Value::String(s) => s.clone(),
        Value::Null => "null".to_string(),
        other => other.to_string(),
    }
}

/// Evaluate a `when:` guard against the current context.
///
/// The guard language is intentionally small: a JSONPath (resolved against
/// a synthetic document of all step outputs plus env/var namespaces) is
/// truthy when it matches a truthy value. An optional trailing
/// `| length <op> <n>` filter compares the match count, so
/// `${find.users} | length > 0` reads naturally. References inside the path
/// are normalized to a single rooted JSONPath first.
pub fn evaluate_when(expr: &str, ctx: &ResolveContext) -> Result<bool, ReferenceError> {
    let (path_part, filter) = match expr.split_once('|') {
        Some((p, f)) => (p.trim(), Some(f.trim())),
        None => (expr.trim(), None),
    };
    let body = strip_reference_wrapper(path_part);
    let (doc, path) = root_for(&body, ctx)?;
    match filter {
        Some(filter) => eval_length_filter(&doc, &path, filter),
        None => {
            // Truthy when the path resolves to a truthy scalar or a
            // non-empty collection.
            match jsonpath::project(&doc, &path) {
                Ok(value) => Ok(is_truthy(&value)),
                Err(jsonpath::JsonPathError::NoMatch { .. }) => Ok(false),
                Err(other) => Err(other.into()),
            }
        }
    }
}

/// Strip a single `${...}` wrapper if the whole expression is one reference.
fn strip_reference_wrapper(s: &str) -> String {
    let trimmed = s.trim();
    if let Some(inner) = trimmed.strip_prefix("${") {
        if let Some(inner) = inner.strip_suffix('}') {
            return inner.trim().to_string();
        }
    }
    trimmed.to_string()
}

/// Turn a reference body into a (document, JSONPath) pair to query.
///
/// Step references query the step's own output, so `find.users` becomes a
/// query of `$.users` against the `find` step output. A bare JSONPath
/// (already starting with `$`) queries a synthetic document holding every
/// step output by id.
fn root_for(body: &str, ctx: &ResolveContext) -> Result<(Value, String), ReferenceError> {
    if body.starts_with('$') {
        let doc = Value::Object(
            ctx.outputs
                .iter()
                .map(|(k, v)| (k.clone(), v.clone()))
                .collect(),
        );
        return Ok((doc, body.to_string()));
    }
    let (head, rest) = split_head(body);
    let output = ctx
        .outputs
        .get(head)
        .ok_or_else(|| ReferenceError::UnknownStep(body.to_string(), head.to_string()))?
        .clone();
    let path = if rest.is_empty() {
        "$".to_string()
    } else {
        format!("$.{rest}")
    };
    Ok((output, path))
}

fn eval_length_filter(doc: &Value, path: &str, filter: &str) -> Result<bool, ReferenceError> {
    let rest = filter
        .strip_prefix("length")
        .map(str::trim)
        .unwrap_or(filter);
    let (op, rhs) = parse_comparison(rest)?;
    let count = match jsonpath::project(doc, path) {
        Ok(Value::Array(items)) => items.len() as i64,
        Ok(Value::Null) => 0,
        Ok(_) => 1,
        Err(jsonpath::JsonPathError::NoMatch { .. }) => 0,
        Err(other) => return Err(other.into()),
    };
    Ok(compare(count, op, rhs))
}

fn parse_comparison(s: &str) -> Result<(&str, i64), ReferenceError> {
    for op in [">=", "<=", "==", "!=", ">", "<"] {
        if let Some(rhs) = s.strip_prefix(op) {
            let n: i64 = rhs.trim().parse().map_err(|_| {
                ReferenceError::Path(jsonpath::JsonPathError::NoMatch {
                    path: format!("when filter has non-integer right side: `{s}`"),
                })
            })?;
            return Ok((op, n));
        }
    }
    Err(ReferenceError::Path(jsonpath::JsonPathError::NoMatch {
        path: format!("unrecognized when filter: `{s}`"),
    }))
}

fn compare(lhs: i64, op: &str, rhs: i64) -> bool {
    match op {
        ">" => lhs > rhs,
        "<" => lhs < rhs,
        ">=" => lhs >= rhs,
        "<=" => lhs <= rhs,
        "==" => lhs == rhs,
        "!=" => lhs != rhs,
        _ => false,
    }
}

fn is_truthy(value: &Value) -> bool {
    match value {
        Value::Null => false,
        Value::Bool(b) => *b,
        Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(true),
        Value::String(s) => !s.is_empty(),
        Value::Array(a) => !a.is_empty(),
        Value::Object(o) => !o.is_empty(),
    }
}

/// Build an empty resolve context for tests and the first step (no prior
/// output, no vars). Exposed so the executor can seed a run.
pub fn empty_outputs() -> HashMap<String, Value> {
    HashMap::new()
}

/// Convenience used by the dry-run planner to surface which references a
/// step names without resolving them.
pub fn reference_bodies(value: &Value) -> Vec<String> {
    let mut found = Vec::new();
    collect_reference_bodies(value, &mut found);
    found
}

fn collect_reference_bodies(value: &Value, out: &mut Vec<String>) {
    match value {
        Value::String(s) => {
            for (_, _, body) in find_references(s) {
                out.push(body);
            }
        }
        Value::Array(items) => items.iter().for_each(|i| collect_reference_bodies(i, out)),
        Value::Object(map) => map.values().for_each(|v| collect_reference_bodies(v, out)),
        _ => {}
    }
}

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

    fn ctx<'a>(
        outputs: &'a HashMap<String, Value>,
        vars: &'a HashMap<String, String>,
    ) -> ResolveContext<'a> {
        ResolveContext { outputs, vars }
    }

    #[test]
    fn whole_string_reference_preserves_json_type() {
        let mut outputs = HashMap::new();
        outputs.insert("find".to_string(), json!({ "users": [1, 2, 3] }));
        let vars = HashMap::new();
        let resolved = resolve_args(&json!("${find.users}"), &ctx(&outputs, &vars)).unwrap();
        assert_eq!(resolved, json!([1, 2, 3]));
    }

    #[test]
    fn indexed_step_reference_extracts_scalar() {
        let mut outputs = HashMap::new();
        outputs.insert(
            "find".to_string(),
            json!({ "users": [{ "id": "u1" }, { "id": "u2" }] }),
        );
        let vars = HashMap::new();
        let resolved = resolve_args(
            &json!({ "user_id": "${find.users[0].id}" }),
            &ctx(&outputs, &vars),
        )
        .unwrap();
        assert_eq!(resolved, json!({ "user_id": "u1" }));
    }

    #[test]
    fn mixed_text_interpolates_reference_as_string() {
        let mut outputs = HashMap::new();
        outputs.insert("details".to_string(), json!({ "name": "Alice" }));
        let vars = HashMap::new();
        let resolved = resolve_args(
            &json!({ "greeting": "Hi ${details.name}, welcome" }),
            &ctx(&outputs, &vars),
        )
        .unwrap();
        assert_eq!(resolved, json!({ "greeting": "Hi Alice, welcome" }));
    }

    #[test]
    fn var_reference_resolves_from_cli_vars() {
        let outputs = HashMap::new();
        let mut vars = HashMap::new();
        vars.insert("QUERY".to_string(), "alice".to_string());
        let resolved =
            resolve_args(&json!({ "q": "${var.QUERY}" }), &ctx(&outputs, &vars)).unwrap();
        assert_eq!(resolved, json!({ "q": "alice" }));
    }

    #[test]
    fn undefined_var_is_an_error() {
        let outputs = HashMap::new();
        let vars = HashMap::new();
        let err = resolve_args(&json!("${var.NOPE}"), &ctx(&outputs, &vars)).unwrap_err();
        assert!(matches!(err, ReferenceError::UndefinedVar(_)));
    }

    #[test]
    fn unknown_step_reference_is_an_error() {
        let outputs = HashMap::new();
        let vars = HashMap::new();
        let err = resolve_args(&json!("${ghost.field}"), &ctx(&outputs, &vars)).unwrap_err();
        assert!(matches!(err, ReferenceError::UnknownStep(_, _)));
    }

    #[test]
    fn when_truthy_on_nonempty_array_via_length_filter() {
        let mut outputs = HashMap::new();
        outputs.insert("find".to_string(), json!({ "users": [1, 2] }));
        let vars = HashMap::new();
        assert!(evaluate_when("${find.users} | length > 0", &ctx(&outputs, &vars)).unwrap());
    }

    #[test]
    fn when_false_on_empty_array_via_length_filter() {
        let mut outputs = HashMap::new();
        outputs.insert("find".to_string(), json!({ "users": [] }));
        let vars = HashMap::new();
        assert!(!evaluate_when("${find.users} | length > 0", &ctx(&outputs, &vars)).unwrap());
    }

    #[test]
    fn when_truthy_on_boolean_field() {
        let mut outputs = HashMap::new();
        outputs.insert("check".to_string(), json!({ "ok": true }));
        let vars = HashMap::new();
        assert!(evaluate_when("${check.ok}", &ctx(&outputs, &vars)).unwrap());
    }

    #[test]
    fn when_false_on_missing_field() {
        let mut outputs = HashMap::new();
        outputs.insert("check".to_string(), json!({ "ok": true }));
        let vars = HashMap::new();
        assert!(!evaluate_when("${check.missing}", &ctx(&outputs, &vars)).unwrap());
    }

    #[test]
    fn reference_bodies_lists_named_refs() {
        let bodies = reference_bodies(&json!({
            "a": "${find.id}",
            "b": ["${var.X}", "literal"]
        }));
        assert!(bodies.contains(&"find.id".to_string()));
        assert!(bodies.contains(&"var.X".to_string()));
        assert_eq!(bodies.len(), 2);
    }
}
