//! Workflow `when` condition evaluation (v1.2 Workflows) — pure, DB-free.
//!
//! A step's optional `when` is a small, safe JSON predicate over the run
//! context (`input` + prior step `output`s, resolved via [`workflow_template`]).
//! When it evaluates false the step is `skipped`. This keeps `manager-core`
//! free of a scripting engine (the boundary rule) — it is NOT Rhai and never
//! grows into one; a `when` that ever needs real scripting is a deliberate
//! future ExecutorClient round-trip, not an engine dependency here.
//!
//! Grammar (precedence low→high): `||`, `&&`, `!`, comparison
//! (`== != < > <= >=`), primary (`( … )`, `exists <ref>`, literal, `<ref>`).
//! Literals: numbers, `'…'`/`"…"` strings, `true`, `false`, `null`.
//! A bare reference is truthy per [`is_truthy`]; a reference that doesn't
//! resolve is `null` (falsy) at run time. This is a DELIBERATE asymmetry with
//! [`workflow_template`], which HARD-FAILS a missing ref: a `when` is a
//! predicate (an absent value is "condition not met"), whereas an input
//! template must not silently substitute null. Apply-time [`validate`] guards
//! the *shape* of a ref (the root `input`/`steps.<name>.output` prefix and that
//! `<name>` is a declared step), but it cannot check a deeper JSON path against
//! a runtime value — so a typo *past* the step name (e.g. `steps.a.output.typ`
//! for `…typo`) is not caught at apply and silently makes the step skip. Prefer
//! `exists <ref>` to make "the value may be absent" explicit.
//!
//! [`workflow_template`]: crate::workflow_template

use std::collections::BTreeSet;

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

use crate::workflow_template::{RunContext, TemplateError};

#[derive(Debug, Error, PartialEq, Eq)]
pub enum ExprError {
    #[error("workflow `when` parse error: {0}")]
    Parse(String),
    #[error("workflow `when` reference error: {0}")]
    Ref(#[from] TemplateError),
}

/// Parse a `when` expression (syntactic validation). Returns the AST.
///
/// # Errors
/// [`ExprError::Parse`] on malformed syntax.
pub fn parse(src: &str) -> Result<Expr, ExprError> {
    let toks = lex(src)?;
    let mut p = Parser {
        toks: &toks,
        pos: 0,
    };
    let e = p.parse_or()?;
    if p.pos != p.toks.len() {
        return Err(ExprError::Parse(format!(
            "trailing tokens near {:?}",
            p.toks.get(p.pos)
        )));
    }
    Ok(e)
}

/// Parse + evaluate `src` against `ctx`.
///
/// # Errors
/// [`ExprError::Parse`] on malformed syntax.
pub fn eval(src: &str, ctx: &RunContext) -> Result<bool, ExprError> {
    Ok(eval_bool(&parse(src)?, ctx))
}

/// Apply-time validation: parse, then check every referenced `steps.<name>`
/// names a declared step.
///
/// # Errors
/// [`ExprError`] on a parse error or a reference to an undeclared step.
pub fn validate(src: &str, step_names: &BTreeSet<String>) -> Result<(), ExprError> {
    let ast = parse(src)?;
    let mut refs = Vec::new();
    ast.collect_refs(&mut refs);
    for r in refs {
        let parts: Vec<&str> = r.split('.').collect();
        match parts.first().copied() {
            Some("input") => {}
            Some("steps") => {
                let name = parts
                    .get(1)
                    .filter(|s| !s.is_empty())
                    .ok_or_else(|| ExprError::Ref(TemplateError::MalformedRef(r.clone())))?;
                if parts.get(2).copied() != Some("output") {
                    return Err(ExprError::Ref(TemplateError::MalformedRef(r.clone())));
                }
                if !step_names.contains(*name) {
                    return Err(ExprError::Ref(TemplateError::UnknownStep(
                        r.clone(),
                        (*name).to_string(),
                    )));
                }
            }
            _ => return Err(ExprError::Ref(TemplateError::MalformedRef(r.clone()))),
        }
    }
    Ok(())
}

// ---- AST ------------------------------------------------------------------

#[derive(Debug, Clone, PartialEq)]
pub enum Expr {
    Or(Box<Expr>, Box<Expr>),
    And(Box<Expr>, Box<Expr>),
    Not(Box<Expr>),
    Cmp(Operand, CmpOp, Operand),
    Truthy(Operand),
    Exists(String),
}

#[derive(Debug, Clone, PartialEq)]
pub enum Operand {
    Ref(String),
    Lit(Value),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CmpOp {
    Eq,
    Ne,
    Lt,
    Gt,
    Le,
    Ge,
}

impl Expr {
    fn collect_refs(&self, acc: &mut Vec<String>) {
        match self {
            Self::Or(a, b) | Self::And(a, b) => {
                a.collect_refs(acc);
                b.collect_refs(acc);
            }
            Self::Not(a) => a.collect_refs(acc),
            Self::Cmp(l, _, r) => {
                l.collect_ref(acc);
                r.collect_ref(acc);
            }
            Self::Truthy(o) => o.collect_ref(acc),
            Self::Exists(r) => acc.push(r.clone()),
        }
    }
}

impl Operand {
    fn collect_ref(&self, acc: &mut Vec<String>) {
        if let Self::Ref(r) = self {
            acc.push(r.clone());
        }
    }
}

// ---- Evaluation -----------------------------------------------------------

fn eval_bool(e: &Expr, ctx: &RunContext) -> bool {
    match e {
        Expr::Or(a, b) => eval_bool(a, ctx) || eval_bool(b, ctx),
        Expr::And(a, b) => eval_bool(a, ctx) && eval_bool(b, ctx),
        Expr::Not(a) => !eval_bool(a, ctx),
        Expr::Truthy(o) => is_truthy(&operand_value(o, ctx)),
        Expr::Exists(r) => ctx.resolve_ref(r).is_some_and(|v| !v.is_null()),
        Expr::Cmp(l, op, r) => {
            let lv = operand_value(l, ctx);
            let rv = operand_value(r, ctx);
            eval_cmp(&lv, *op, &rv)
        }
    }
}

fn operand_value(o: &Operand, ctx: &RunContext) -> Value {
    match o {
        Operand::Lit(v) => v.clone(),
        Operand::Ref(r) => ctx.resolve_ref(r).cloned().unwrap_or(Value::Null),
    }
}

/// JSON truthiness: null/false/0/""/[]/{}` are falsy; everything else truthy.
#[must_use]
pub fn is_truthy(v: &Value) -> bool {
    match v {
        Value::Null => false,
        Value::Bool(b) => *b,
        Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
        Value::String(s) => !s.is_empty(),
        Value::Array(a) => !a.is_empty(),
        Value::Object(m) => !m.is_empty(),
    }
}

fn eval_cmp(l: &Value, op: CmpOp, r: &Value) -> bool {
    match op {
        CmpOp::Eq => json_eq(l, r),
        CmpOp::Ne => !json_eq(l, r),
        CmpOp::Lt | CmpOp::Gt | CmpOp::Le | CmpOp::Ge => match order(l, r) {
            Some(ord) => match op {
                CmpOp::Lt => ord.is_lt(),
                CmpOp::Gt => ord.is_gt(),
                CmpOp::Le => ord.is_le(),
                CmpOp::Ge => ord.is_ge(),
                _ => unreachable!(),
            },
            None => false,
        },
    }
}

fn json_eq(l: &Value, r: &Value) -> bool {
    match (l.as_f64(), r.as_f64()) {
        (Some(a), Some(b)) => (a - b).abs() < f64::EPSILON,
        _ => l == r,
    }
}

fn order(l: &Value, r: &Value) -> Option<std::cmp::Ordering> {
    if let (Some(a), Some(b)) = (l.as_f64(), r.as_f64()) {
        return a.partial_cmp(&b);
    }
    if let (Value::String(a), Value::String(b)) = (l, r) {
        return Some(a.cmp(b));
    }
    None
}

// ---- Lexer ----------------------------------------------------------------

#[derive(Debug, Clone, PartialEq)]
enum Tok {
    Word(String),
    Num(f64),
    Str(String),
    Op(&'static str),
    LParen,
    RParen,
}

#[allow(clippy::too_many_lines)]
fn lex(src: &str) -> Result<Vec<Tok>, ExprError> {
    let b = src.as_bytes();
    let mut i = 0;
    let mut out = Vec::new();
    while i < b.len() {
        let c = b[i];
        match c {
            _ if c.is_ascii_whitespace() => i += 1,
            b'(' => {
                out.push(Tok::LParen);
                i += 1;
            }
            b')' => {
                out.push(Tok::RParen);
                i += 1;
            }
            b'\'' | b'"' => {
                let quote = c;
                let start = i + 1;
                let mut j = start;
                while j < b.len() && b[j] != quote {
                    j += 1;
                }
                if j >= b.len() {
                    return Err(ExprError::Parse("unterminated string".to_string()));
                }
                out.push(Tok::Str(src[start..j].to_string()));
                i = j + 1;
            }
            b'0'..=b'9' | b'-' if c != b'-' || next_is_digit(b, i) => {
                let start = i;
                i += 1;
                while i < b.len() && (b[i].is_ascii_digit() || b[i] == b'.') {
                    i += 1;
                }
                let f: f64 = src[start..i]
                    .parse()
                    .map_err(|_| ExprError::Parse(format!("bad number `{}`", &src[start..i])))?;
                out.push(Tok::Num(f));
            }
            b'=' | b'!' | b'<' | b'>' | b'&' | b'|' => {
                let two = if i + 1 < b.len() { &src[i..=i + 1] } else { "" };
                match two {
                    "==" => two_op(&mut out, &mut i, "=="),
                    "!=" => two_op(&mut out, &mut i, "!="),
                    "<=" => two_op(&mut out, &mut i, "<="),
                    ">=" => two_op(&mut out, &mut i, ">="),
                    "&&" => two_op(&mut out, &mut i, "&&"),
                    "||" => two_op(&mut out, &mut i, "||"),
                    _ => match c {
                        b'<' => one_op(&mut out, &mut i, "<"),
                        b'>' => one_op(&mut out, &mut i, ">"),
                        b'!' => one_op(&mut out, &mut i, "!"),
                        _ => return Err(ExprError::Parse(format!("unexpected `{}`", c as char))),
                    },
                }
            }
            _ if c.is_ascii_alphabetic() || c == b'_' => {
                let start = i;
                while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'.')
                {
                    i += 1;
                }
                out.push(Tok::Word(src[start..i].to_string()));
            }
            _ => return Err(ExprError::Parse(format!("unexpected `{}`", c as char))),
        }
    }
    Ok(out)
}

fn next_is_digit(b: &[u8], i: usize) -> bool {
    b.get(i + 1).is_some_and(u8::is_ascii_digit)
}

fn two_op(out: &mut Vec<Tok>, i: &mut usize, op: &'static str) {
    out.push(Tok::Op(op));
    *i += 2;
}

fn one_op(out: &mut Vec<Tok>, i: &mut usize, op: &'static str) {
    out.push(Tok::Op(op));
    *i += 1;
}

// ---- Parser ---------------------------------------------------------------

struct Parser<'a> {
    toks: &'a [Tok],
    pos: usize,
}

impl Parser<'_> {
    fn peek(&self) -> Option<&Tok> {
        self.toks.get(self.pos)
    }

    fn parse_or(&mut self) -> Result<Expr, ExprError> {
        let mut left = self.parse_and()?;
        while matches!(self.peek(), Some(Tok::Op("||"))) {
            self.pos += 1;
            let right = self.parse_and()?;
            left = Expr::Or(Box::new(left), Box::new(right));
        }
        Ok(left)
    }

    fn parse_and(&mut self) -> Result<Expr, ExprError> {
        let mut left = self.parse_not()?;
        while matches!(self.peek(), Some(Tok::Op("&&"))) {
            self.pos += 1;
            let right = self.parse_not()?;
            left = Expr::And(Box::new(left), Box::new(right));
        }
        Ok(left)
    }

    fn parse_not(&mut self) -> Result<Expr, ExprError> {
        if matches!(self.peek(), Some(Tok::Op("!"))) {
            self.pos += 1;
            return Ok(Expr::Not(Box::new(self.parse_not()?)));
        }
        self.parse_cmp()
    }

    fn parse_cmp(&mut self) -> Result<Expr, ExprError> {
        // `( … )` group is a full boolean sub-expression.
        if matches!(self.peek(), Some(Tok::LParen)) {
            self.pos += 1;
            let inner = self.parse_or()?;
            if !matches!(self.peek(), Some(Tok::RParen)) {
                return Err(ExprError::Parse("expected `)`".to_string()));
            }
            self.pos += 1;
            return Ok(inner);
        }
        // `exists <ref>`
        if matches!(self.peek(), Some(Tok::Word(w)) if w == "exists") {
            self.pos += 1;
            let Some(Tok::Word(r)) = self.peek().cloned() else {
                return Err(ExprError::Parse(
                    "expected reference after `exists`".to_string(),
                ));
            };
            self.pos += 1;
            return Ok(Expr::Exists(r));
        }
        let left = self.parse_operand()?;
        if let Some(op) = self.peek().and_then(cmp_op) {
            self.pos += 1;
            let right = self.parse_operand()?;
            return Ok(Expr::Cmp(left, op, right));
        }
        Ok(Expr::Truthy(left))
    }

    fn parse_operand(&mut self) -> Result<Operand, ExprError> {
        match self.peek().cloned() {
            Some(Tok::Num(n)) => {
                self.pos += 1;
                Ok(Operand::Lit(serde_json::json!(n)))
            }
            Some(Tok::Str(s)) => {
                self.pos += 1;
                Ok(Operand::Lit(Value::String(s)))
            }
            Some(Tok::Word(w)) => {
                self.pos += 1;
                Ok(match w.as_str() {
                    "true" => Operand::Lit(Value::Bool(true)),
                    "false" => Operand::Lit(Value::Bool(false)),
                    "null" => Operand::Lit(Value::Null),
                    _ => Operand::Ref(w),
                })
            }
            other => Err(ExprError::Parse(format!(
                "expected operand, found {other:?}"
            ))),
        }
    }
}

fn cmp_op(t: &Tok) -> Option<CmpOp> {
    match t {
        Tok::Op("==") => Some(CmpOp::Eq),
        Tok::Op("!=") => Some(CmpOp::Ne),
        Tok::Op("<") => Some(CmpOp::Lt),
        Tok::Op(">") => Some(CmpOp::Gt),
        Tok::Op("<=") => Some(CmpOp::Le),
        Tok::Op(">=") => Some(CmpOp::Ge),
        _ => None,
    }
}

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

    fn ctx() -> RunContext {
        let mut steps = BTreeMap::new();
        steps.insert("check".to_string(), json!({ "ok": true, "score": 7 }));
        RunContext {
            input: json!({ "amount": 100, "mode": "prod", "flag": false }),
            steps,
        }
    }

    #[test]
    fn comparisons() {
        let c = ctx();
        assert!(eval("input.amount > 50", &c).unwrap());
        assert!(!eval("input.amount > 500", &c).unwrap());
        assert!(eval("input.amount == 100", &c).unwrap());
        assert!(eval("input.mode == 'prod'", &c).unwrap());
        assert!(eval("steps.check.output.score >= 7", &c).unwrap());
        assert!(eval("steps.check.output.ok == true", &c).unwrap());
    }

    #[test]
    fn boolean_ops_and_precedence() {
        let c = ctx();
        assert!(eval("input.amount > 50 && input.mode == 'prod'", &c).unwrap());
        assert!(!eval("input.amount > 50 && input.mode == 'dev'", &c).unwrap());
        assert!(eval("input.amount > 500 || input.mode == 'prod'", &c).unwrap());
        assert!(eval("!(input.mode == 'dev')", &c).unwrap());
        // && binds tighter than ||
        assert!(eval("false && false || input.amount == 100", &c).unwrap());
    }

    #[test]
    fn truthiness_and_exists() {
        let c = ctx();
        assert!(eval("steps.check.output.ok", &c).unwrap());
        assert!(!eval("input.flag", &c).unwrap());
        assert!(eval("exists input.amount", &c).unwrap());
        assert!(!eval("exists input.nope", &c).unwrap());
        // a missing ref is falsy, not an error, in a condition
        assert!(!eval("input.nope == 1", &c).unwrap());
    }

    #[test]
    fn parse_errors() {
        assert!(parse("input.a >").is_err());
        assert!(parse("&& input.a").is_err());
        assert!(parse("(input.a == 1").is_err());
        assert!(parse("'unterminated").is_err());
    }

    #[test]
    fn validate_checks_declared_steps() {
        let steps: BTreeSet<String> = ["check".to_string()].into_iter().collect();
        assert!(validate("steps.check.output.ok && input.x > 1", &steps).is_ok());
        assert!(validate("steps.ghost.output.ok", &steps).is_err());
        assert!(validate("steps.check.result", &steps).is_err());
    }
}
