//! Mini boolean expression parser for `requires_approval_if` conditions.
//!
//! Public surface: [`evaluate`].
//!
//! Grammar (flat, no parentheses in v1):
//! ```text
//! expr       := clause (combinator clause)*
//! clause     := field op literal
//! combinator := "AND" | "OR"
//! field      := "tool" | "path" | "url" | "method" | "command"
//!             | "args." dotted_path        # walks ToolCall.args JSON
//!             | "tool_result." dotted_path # walks ToolResult.result JSON
//!             | "tool_result"              # whole ToolResult.result body
//!             | (other identifiers — see KNOWN_VARIABLES)
//! op         := "==" | "!=" | ">" | ">=" | "<" | "<=" | "contains" | "starts_with"
//!             | "in" | "not_in"
//! literal    := quoted_string | integer | float | list
//! ```
//!
//! ## `args.<key>` predicates
//!
//! Identifiers prefixed with `args.` walk the JSON object on a
//! [`GovernanceAction::ToolCall`]'s `args` field via a JSON pointer
//! synthesised from the dotted path (`args.path` → `/path`,
//! `args.headers.authorization` → `/headers/authorization`). Resolved
//! string leaves accept `== != contains starts_with in not_in`;
//! resolved numeric leaves accept `== != > >= < <=`. Non-`ToolCall`
//! actions, malformed `args` JSON, and unresolved pointers all surface
//! as no-match (false) — never fail-safe-true.
//!
//! ## `tool_result.<key>` predicates (response side)
//!
//! Response-side mirror of `args.<key>`: identifiers prefixed with
//! `tool_result.` walk the JSON body on a
//! [`GovernanceAction::ToolResult`]'s `result` field via the same
//! JSON-pointer translation (`tool_result.foo` → `/foo`,
//! `tool_result.payload.api_key` → `/payload/api_key`). Op coverage
//! and null-safety match `args.<key>` exactly.
//!
//! The bare identifier `tool_result` (no dotted suffix) is a special
//! shorthand that treats the entire serialised `result` body as one
//! string. Only `contains` / `starts_with` against a string literal
//! are meaningful here — useful for regex-style pattern matches like
//! `tool_result contains "sk-"` when the body schema is unknown.
//!
//! **Fail-safe**: any parse/tokenization error returns `true`
//! (triggers RequiresApproval — the safe default).

// The private helpers below are only consumed via `evaluate` which is
// `pub(crate)`.  Until a caller in this crate wires up the evaluator,
// rustc sees them as dead code.  The allow is intentional and temporary.
#![allow(dead_code)]

use aa_core::{GovernanceAction, GovernanceLevel};

use crate::policy::context::PolicyContext;

use strsim;

/// All variable names that the expression evaluator recognises.
///
/// Used by load-time validation to catch typos before a policy is ever
/// evaluated.  Any identifier in a `requires_approval_if` expression that is
/// not in this list and is not a combinator, operator, governance-level literal,
/// or numeric literal will be rejected with
/// [`PolicyParseError::UnknownVariable`](crate::policy::error::PolicyParseError::UnknownVariable).
pub(crate) const KNOWN_VARIABLES: &[&str] = &[
    "tool",
    "path",
    "url",
    "method",
    "command",
    "governance_level",
    "agent.depth",
    "agent.risk_tier",
    "team.active_agents",
    "team.budget_remaining",
    "child.tool",
    "child.risk_tier",
    "parent.risk_tier",
    "source.team_id",
    "target.team_id",
    "target.channel_id",
    "agent.age",
    "team.parallel_agents",
    "agent.parent_agent_id",
    "agent.team_id",
    "agent.children_count",
    "agent.is_root",
    "agent.is_leaf",
];

// ---------------------------------------------------------------------------
// Internal token types
// ---------------------------------------------------------------------------

#[derive(Debug, PartialEq)]
enum FieldRef {
    Tool,
    Path,
    Url,
    Method,
    Command,
    GovernanceLevel,
    AgentDepth,
    TeamActiveAgents,
    TeamBudgetRemaining,
    ChildTool,
    ChildRiskTier,
    AgentRiskTier,
    ParentRiskTier,
    SourceTeamId,
    TargetTeamId,
    TargetChannelId,
    AgentAge,
    TeamParallelAgents,
    AgentParentId,
    AgentTeamId,
    AgentChildrenCount,
    AgentIsRoot,
    AgentIsLeaf,
    /// `args.<key>` / `args.<key>.<nested>` — walks the `args` JSON object on
    /// a `GovernanceAction::ToolCall` via the carried JSON-pointer path
    /// (e.g. `args.path` → `"/path"`, `args.config.timeout_ms` →
    /// `"/config/timeout_ms"`). Surfaces a leaf scalar to the predicate
    /// evaluator; null-safe no-match when the pointer cannot resolve or the
    /// `args` payload is not valid JSON.
    ToolArg(String),
    /// `tool_result.<key>` / `tool_result.<key>.<nested>` — walks the `result`
    /// JSON body on a `GovernanceAction::ToolResult` via the carried
    /// JSON-pointer path. Mirrors `ToolArg`'s null-safety contract: non-
    /// `ToolResult` actions, malformed `result` JSON, and unresolved
    /// pointers all surface as no-match (false).
    ToolResult(String),
    /// Bare `tool_result` (no dotted path) — treats the entire serialised
    /// `result` body of a `GovernanceAction::ToolResult` as one string for
    /// regex-style `contains` / `starts_with` matches. Lets policies write
    /// `tool_result contains "sk-"` without committing to a specific JSON
    /// field shape, useful when the response body's schema is unknown.
    ToolResultWhole,
}

#[derive(Debug, PartialEq)]
enum OpKind {
    Eq,
    Ne,
    Gt,
    Gte,
    Lt,
    Lte,
    Contains,
    StartsWith,
    In,
    NotIn,
}

#[derive(Debug, PartialEq)]
enum LiteralVal {
    Str(String),
    Num(f64),
    Level(GovernanceLevel),
    Tier(aa_core::RiskTier),
    StrList(Vec<String>),
    /// Duration in seconds, parsed from human-readable strings like `24h`, `30m`.
    Duration(u64),
}

#[derive(Debug, PartialEq)]
enum Token {
    Field(FieldRef),
    Op(OpKind),
    Literal(LiteralVal),
    And,
    Or,
}

// ---------------------------------------------------------------------------
// Tokenizer
// ---------------------------------------------------------------------------

fn tokenize(expr: &str) -> Option<Vec<Token>> {
    let mut tokens = Vec::new();
    let mut chars = expr.chars().peekable();

    while let Some(&ch) = chars.peek() {
        // Skip whitespace
        if ch.is_whitespace() {
            chars.next();
            continue;
        }

        // List literal: ["item1", "item2", ...]
        if ch == '[' {
            chars.next(); // consume '['
            let mut items: Vec<String> = Vec::new();
            loop {
                // skip whitespace
                while let Some(&c) = chars.peek() {
                    if c.is_whitespace() {
                        chars.next();
                    } else {
                        break;
                    }
                }
                match chars.peek() {
                    Some(&']') => {
                        chars.next();
                        break;
                    }
                    Some(&'"') => {
                        chars.next(); // consume opening quote
                        let mut s = String::new();
                        loop {
                            match chars.next() {
                                Some('"') => break,
                                Some('\\') => match chars.next() {
                                    Some('"') => s.push('"'),
                                    Some('\\') => s.push('\\'),
                                    Some(c) => {
                                        s.push('\\');
                                        s.push(c);
                                    }
                                    None => return None,
                                },
                                Some(c) => s.push(c),
                                None => return None,
                            }
                        }
                        items.push(s);
                        // skip whitespace and optional comma
                        while let Some(&c) = chars.peek() {
                            if c.is_whitespace() || c == ',' {
                                chars.next();
                            } else {
                                break;
                            }
                        }
                    }
                    _ => return None, // unexpected token in list
                }
            }
            tokens.push(Token::Literal(LiteralVal::StrList(items)));
            continue;
        }

        // Quoted string literal
        if ch == '"' {
            chars.next(); // consume opening quote
            let mut s = String::new();
            loop {
                match chars.next() {
                    Some('"') => break,
                    Some('\\') => {
                        // basic escape: \" and \\
                        match chars.next() {
                            Some('"') => s.push('"'),
                            Some('\\') => s.push('\\'),
                            Some(c) => {
                                s.push('\\');
                                s.push(c);
                            }
                            None => return None, // unterminated escape
                        }
                    }
                    Some(c) => s.push(c),
                    None => return None, // unterminated string
                }
            }
            tokens.push(Token::Literal(LiteralVal::Str(s)));
            continue;
        }

        // Operator tokens that start with '<', '>', '=', '!'
        if ch == '<' || ch == '>' || ch == '=' || ch == '!' {
            chars.next();
            let op = if chars.peek() == Some(&'=') {
                chars.next();
                match ch {
                    '<' => OpKind::Lte,
                    '>' => OpKind::Gte,
                    '=' => OpKind::Eq,
                    '!' => OpKind::Ne,
                    _ => return None,
                }
            } else {
                match ch {
                    '<' => OpKind::Lt,
                    '>' => OpKind::Gt,
                    _ => return None, // bare '=' or '!' without '=' is invalid
                }
            };
            tokens.push(Token::Op(op));
            continue;
        }

        // Word tokens: keywords, field names, operators, numeric literals
        if ch.is_alphanumeric() || ch == '_' || ch == '-' || ch == '.' {
            let mut word = String::new();
            while let Some(&c) = chars.peek() {
                if c.is_alphanumeric() || c == '_' || c == '-' || c == '.' {
                    word.push(c);
                    chars.next();
                } else {
                    break;
                }
            }

            // `args.<key>` / `args.<key>.<nested>` — synthesise a
            // `FieldRef::ToolArg(json_pointer)`. Translates the dotted
            // identifier into a JSON pointer the evaluator walks against
            // the ToolCall's `args` JSON object. Empty pointer (bare
            // `args`) is rejected so policies must reference a concrete
            // field.
            if let Some(rest) = word.strip_prefix("args.") {
                if rest.is_empty() {
                    return None;
                }
                let pointer = format!("/{}", rest.replace('.', "/"));
                tokens.push(Token::Field(FieldRef::ToolArg(pointer)));
                continue;
            }

            // `tool_result.<key>` / `tool_result.<key>.<nested>` — response-side
            // mirror of `args.<key>`. Resolves to `FieldRef::ToolResult(pointer)`
            // when a dotted path follows; the bare `tool_result` (no path) maps
            // to `FieldRef::ToolResultWhole` so policies can match the entire
            // serialised response as one string.
            if let Some(rest) = word.strip_prefix("tool_result.") {
                if rest.is_empty() {
                    return None;
                }
                let pointer = format!("/{}", rest.replace('.', "/"));
                tokens.push(Token::Field(FieldRef::ToolResult(pointer)));
                continue;
            }
            if word == "tool_result" {
                tokens.push(Token::Field(FieldRef::ToolResultWhole));
                continue;
            }

            let token = match word.as_str() {
                "AND" => Token::And,
                "OR" => Token::Or,
                "tool" => Token::Field(FieldRef::Tool),
                "path" => Token::Field(FieldRef::Path),
                "url" => Token::Field(FieldRef::Url),
                "method" => Token::Field(FieldRef::Method),
                "command" => Token::Field(FieldRef::Command),
                "governance_level" => Token::Field(FieldRef::GovernanceLevel),
                "agent.depth" => Token::Field(FieldRef::AgentDepth),
                "team.active_agents" => Token::Field(FieldRef::TeamActiveAgents),
                "team.budget_remaining" => Token::Field(FieldRef::TeamBudgetRemaining),
                "child.tool" => Token::Field(FieldRef::ChildTool),
                "child.risk_tier" => Token::Field(FieldRef::ChildRiskTier),
                "agent.risk_tier" => Token::Field(FieldRef::AgentRiskTier),
                "parent.risk_tier" => Token::Field(FieldRef::ParentRiskTier),
                "source.team_id" => Token::Field(FieldRef::SourceTeamId),
                "target.team_id" => Token::Field(FieldRef::TargetTeamId),
                "target.channel_id" => Token::Field(FieldRef::TargetChannelId),
                "agent.age" => Token::Field(FieldRef::AgentAge),
                "team.parallel_agents" => Token::Field(FieldRef::TeamParallelAgents),
                "agent.parent_agent_id" => Token::Field(FieldRef::AgentParentId),
                "agent.team_id" => Token::Field(FieldRef::AgentTeamId),
                "agent.children_count" => Token::Field(FieldRef::AgentChildrenCount),
                "agent.is_root" => Token::Field(FieldRef::AgentIsRoot),
                "agent.is_leaf" => Token::Field(FieldRef::AgentIsLeaf),
                "L0" => Token::Literal(LiteralVal::Level(GovernanceLevel::L0Discover)),
                "L1" => Token::Literal(LiteralVal::Level(GovernanceLevel::L1Observe)),
                "L2" => Token::Literal(LiteralVal::Level(GovernanceLevel::L2Enforce)),
                "L3" => Token::Literal(LiteralVal::Level(GovernanceLevel::L3Native)),
                "Low" => Token::Literal(LiteralVal::Tier(aa_core::RiskTier::Low)),
                "Medium" => Token::Literal(LiteralVal::Tier(aa_core::RiskTier::Medium)),
                "High" => Token::Literal(LiteralVal::Tier(aa_core::RiskTier::High)),
                "Critical" => Token::Literal(LiteralVal::Tier(aa_core::RiskTier::Critical)),
                "contains" => Token::Op(OpKind::Contains),
                "starts_with" => Token::Op(OpKind::StartsWith),
                "in" => Token::Op(OpKind::In),
                "not_in" => Token::Op(OpKind::NotIn),
                other => {
                    // Try to parse as a number
                    if let Ok(n) = other.parse::<f64>() {
                        Token::Literal(LiteralVal::Num(n))
                    } else if other.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false) {
                        // Try to parse as a humantime duration (e.g. "24h", "30m", "1h30m").
                        // Only attempt when the word starts with a digit — avoids false positives.
                        if let Ok(d) = humantime::parse_duration(other) {
                            Token::Literal(LiteralVal::Duration(d.as_secs()))
                        } else {
                            return None;
                        }
                    } else {
                        return None; // unknown word
                    }
                }
            };
            tokens.push(token);
            continue;
        }

        // Unknown character
        return None;
    }

    Some(tokens)
}

// ---------------------------------------------------------------------------
// Field value extraction
// ---------------------------------------------------------------------------

fn field_value<'a>(field: &FieldRef, action: &'a GovernanceAction) -> &'a str {
    match (field, action) {
        (FieldRef::Tool, GovernanceAction::ToolCall { name, .. }) => name.as_str(),
        (FieldRef::Path, GovernanceAction::FileAccess { path, .. }) => path.as_str(),
        (FieldRef::Url, GovernanceAction::NetworkRequest { url, .. }) => url.as_str(),
        (FieldRef::Method, GovernanceAction::NetworkRequest { method, .. }) => method.as_str(),
        (FieldRef::Command, GovernanceAction::ProcessExec { command }) => command.as_str(),
        // Field does not match the action variant, or governance_level is
        // handled out-of-band in `eval_clause_safe` → treat as empty string.
        _ => "",
    }
}

// ---------------------------------------------------------------------------
// Clause evaluation
// ---------------------------------------------------------------------------

fn eval_clause_safe(
    field: &FieldRef,
    op: &OpKind,
    literal: &LiteralVal,
    action: &GovernanceAction,
    agent_level: Option<GovernanceLevel>,
    policy_ctx: Option<&dyn PolicyContext>,
) -> bool {
    // agent.depth — numeric comparison against the current agent's delegation depth.
    // Returns false (null-safe no-match) when no context is available.
    if let FieldRef::AgentDepth = field {
        let lhs = match policy_ctx.and_then(|c| c.agent_depth()) {
            Some(d) => d as f64,
            None => return false,
        };
        let rhs = match numeric_literal(literal) {
            Some(r) => r,
            None => return false,
        };
        return match op {
            OpKind::Eq => lhs == rhs,
            OpKind::Ne => lhs != rhs,
            OpKind::Gt => lhs > rhs,
            OpKind::Gte => lhs >= rhs,
            OpKind::Lt => lhs < rhs,
            OpKind::Lte => lhs <= rhs,
            OpKind::Contains | OpKind::StartsWith | OpKind::In | OpKind::NotIn => false,
        };
    }

    // team.active_agents — numeric comparison against the count of agents in the
    // current agent's team. Returns false when the agent has no team (null-safe).
    if let FieldRef::TeamActiveAgents = field {
        let lhs = match policy_ctx.and_then(|c| c.team_active_agents()) {
            Some(n) => n as f64,
            None => return false,
        };
        let rhs = match numeric_literal(literal) {
            Some(r) => r,
            None => return false,
        };
        return match op {
            OpKind::Eq => lhs == rhs,
            OpKind::Ne => lhs != rhs,
            OpKind::Gt => lhs > rhs,
            OpKind::Gte => lhs >= rhs,
            OpKind::Lt => lhs < rhs,
            OpKind::Lte => lhs <= rhs,
            OpKind::Contains | OpKind::StartsWith | OpKind::In | OpKind::NotIn => false,
        };
    }

    // team.budget_remaining — numeric comparison against the remaining monthly
    // budget for the current agent's team. Returns false when no budget entry or
    // no monthly limit is configured (null-safe).
    if let FieldRef::TeamBudgetRemaining = field {
        let lhs = match policy_ctx.and_then(|c| c.team_budget_remaining()) {
            Some(r) => r,
            None => return false,
        };
        let rhs = match numeric_literal(literal) {
            Some(r) => r,
            None => return false,
        };
        return match op {
            OpKind::Eq => lhs == rhs,
            OpKind::Ne => lhs != rhs,
            OpKind::Gt => lhs > rhs,
            OpKind::Gte => lhs >= rhs,
            OpKind::Lt => lhs < rhs,
            OpKind::Lte => lhs <= rhs,
            OpKind::Contains | OpKind::StartsWith | OpKind::In | OpKind::NotIn => false,
        };
    }

    // child.tool — string comparison against the union of tool_names across all
    // direct children of the current agent. Returns false when context is absent.
    if let FieldRef::ChildTool = field {
        let tools = match policy_ctx {
            Some(c) => c.child_tools(),
            None => return false,
        };
        let rhs = match literal {
            LiteralVal::Str(s) => s.as_str(),
            _ => return false,
        };
        return match op {
            OpKind::Eq => tools.iter().any(|t| t == rhs),
            OpKind::Ne => tools.iter().all(|t| t != rhs),
            OpKind::Contains => tools.iter().any(|t| t.contains(rhs)),
            OpKind::StartsWith => tools.iter().any(|t| t.starts_with(rhs)),
            _ => false,
        };
    }

    // `tool_result.<key>` — JSON-pointer walk into the ToolResult's `result`
    // payload, and the bare `tool_result` shorthand for matching the whole
    // serialised body. Mirrors `args.<key>`'s null-safety contract: non-
    // `ToolResult` actions, unparseable result JSON, and unresolved pointers
    // all surface as no-match. The bare `tool_result` arm only accepts
    // `contains` / `starts_with` against a string literal (regex-style
    // pattern matching on the full body); equality / numeric / list ops
    // don't have a sensible whole-body interpretation and fall through to
    // false.
    if matches!(field, FieldRef::ToolResult(_) | FieldRef::ToolResultWhole) {
        let result_str = match action {
            GovernanceAction::ToolResult { result, .. } => result.as_str(),
            _ => return false,
        };
        if let FieldRef::ToolResultWhole = field {
            let lit = match literal {
                LiteralVal::Str(s) => s.as_str(),
                _ => return false,
            };
            return match op {
                OpKind::Contains => result_str.contains(lit),
                OpKind::StartsWith => result_str.starts_with(lit),
                _ => false,
            };
        }
        let pointer = match field {
            FieldRef::ToolResult(p) => p,
            _ => unreachable!(),
        };
        let result_value: serde_json::Value = match serde_json::from_str(result_str) {
            Ok(v) => v,
            Err(_) => return false,
        };
        let resolved = match result_value.pointer(pointer) {
            Some(v) => v,
            None => return false,
        };
        return match op {
            OpKind::Eq | OpKind::Ne => match (resolved, literal) {
                (serde_json::Value::String(s), LiteralVal::Str(lit)) => {
                    if matches!(op, OpKind::Eq) {
                        s == lit
                    } else {
                        s != lit
                    }
                }
                (serde_json::Value::Number(n), LiteralVal::Num(lit)) => match n.as_f64() {
                    Some(v) => {
                        if matches!(op, OpKind::Eq) {
                            (v - *lit).abs() < f64::EPSILON
                        } else {
                            (v - *lit).abs() >= f64::EPSILON
                        }
                    }
                    None => false,
                },
                _ => false,
            },
            OpKind::Contains | OpKind::StartsWith => {
                let value = match resolved.as_str() {
                    Some(s) => s,
                    None => return false,
                };
                let lit = match literal {
                    LiteralVal::Str(s) => s.as_str(),
                    _ => return false,
                };
                if matches!(op, OpKind::Contains) {
                    value.contains(lit)
                } else {
                    value.starts_with(lit)
                }
            }
            OpKind::In | OpKind::NotIn => {
                let value = match resolved.as_str() {
                    Some(s) => s,
                    None => return false,
                };
                let list = match literal {
                    LiteralVal::StrList(items) => items,
                    _ => return false,
                };
                if matches!(op, OpKind::In) {
                    list.iter().any(|item| item == value)
                } else {
                    list.iter().all(|item| item != value)
                }
            }
            OpKind::Gt | OpKind::Gte | OpKind::Lt | OpKind::Lte => {
                let lhs = match resolved.as_f64() {
                    Some(n) => n,
                    None => return false,
                };
                let rhs = match literal {
                    LiteralVal::Num(n) => *n,
                    _ => return false,
                };
                match op {
                    OpKind::Gt => lhs > rhs,
                    OpKind::Gte => lhs >= rhs,
                    OpKind::Lt => lhs < rhs,
                    OpKind::Lte => lhs <= rhs,
                    _ => unreachable!(),
                }
            }
        };
    }

    // `args.<key>` — JSON-pointer walk into the ToolCall's args payload.
    //
    // Null-safe at every step: non-ToolCall actions, unparseable args JSON,
    // and unresolved pointers all surface as no-match rather than erroring.
    // String comparisons require the resolved value to be a JSON string;
    // numeric ops require a JSON number; list membership requires a string.
    // Mixed-type comparisons (e.g. `args.timeout == "30"` against a number)
    // are treated as no-match — policies must match the underlying type.
    if let FieldRef::ToolArg(pointer) = field {
        let args_str = match action {
            GovernanceAction::ToolCall { args, .. } => args.as_str(),
            _ => return false,
        };
        let args_value: serde_json::Value = match serde_json::from_str(args_str) {
            Ok(v) => v,
            Err(_) => return false,
        };
        let resolved = match args_value.pointer(pointer) {
            Some(v) => v,
            None => return false,
        };
        return match op {
            OpKind::Eq | OpKind::Ne => match (resolved, literal) {
                (serde_json::Value::String(s), LiteralVal::Str(lit)) => {
                    if matches!(op, OpKind::Eq) {
                        s == lit
                    } else {
                        s != lit
                    }
                }
                (serde_json::Value::Number(n), LiteralVal::Num(lit)) => match n.as_f64() {
                    Some(v) => {
                        if matches!(op, OpKind::Eq) {
                            (v - *lit).abs() < f64::EPSILON
                        } else {
                            (v - *lit).abs() >= f64::EPSILON
                        }
                    }
                    None => false,
                },
                _ => false,
            },
            OpKind::Contains | OpKind::StartsWith => {
                let value = match resolved.as_str() {
                    Some(s) => s,
                    None => return false,
                };
                let lit = match literal {
                    LiteralVal::Str(s) => s.as_str(),
                    _ => return false,
                };
                if matches!(op, OpKind::Contains) {
                    value.contains(lit)
                } else {
                    value.starts_with(lit)
                }
            }
            OpKind::In | OpKind::NotIn => {
                let value = match resolved.as_str() {
                    Some(s) => s,
                    None => return false,
                };
                let list = match literal {
                    LiteralVal::StrList(items) => items,
                    _ => return false,
                };
                if matches!(op, OpKind::In) {
                    list.iter().any(|item| item == value)
                } else {
                    list.iter().all(|item| item != value)
                }
            }
            OpKind::Gt | OpKind::Gte | OpKind::Lt | OpKind::Lte => {
                let lhs = match resolved.as_f64() {
                    Some(n) => n,
                    None => return false,
                };
                let rhs = match literal {
                    LiteralVal::Num(n) => *n,
                    _ => return false,
                };
                match op {
                    OpKind::Gt => lhs > rhs,
                    OpKind::Gte => lhs >= rhs,
                    OpKind::Lt => lhs < rhs,
                    OpKind::Lte => lhs <= rhs,
                    _ => unreachable!(),
                }
            }
        };
    }

    // agent.risk_tier — ordinal comparison against the current agent's risk tier.
    // Returns false (null-safe no-match) when context or registry lookup is absent.
    if let FieldRef::AgentRiskTier = field {
        let lhs = match policy_ctx.and_then(|c| c.agent_risk_tier()) {
            Some(t) => t,
            None => return false,
        };
        let rhs = match literal {
            LiteralVal::Tier(t) => *t,
            _ => return false,
        };
        return match op {
            OpKind::Eq => lhs == rhs,
            OpKind::Ne => lhs != rhs,
            OpKind::Gt => lhs > rhs,
            OpKind::Gte => lhs >= rhs,
            OpKind::Lt => lhs < rhs,
            OpKind::Lte => lhs <= rhs,
            OpKind::Contains | OpKind::StartsWith | OpKind::In | OpKind::NotIn => false,
        };
    }

    // parent.risk_tier — ordinal comparison against the parent agent's risk tier.
    // Returns false when context is absent, agent has no parent, or parent not in registry.
    if let FieldRef::ParentRiskTier = field {
        let lhs = match policy_ctx.and_then(|c| c.parent_risk_tier()) {
            Some(t) => t,
            None => return false,
        };
        let rhs = match literal {
            LiteralVal::Tier(t) => *t,
            _ => return false,
        };
        return match op {
            OpKind::Eq => lhs == rhs,
            OpKind::Ne => lhs != rhs,
            OpKind::Gt => lhs > rhs,
            OpKind::Gte => lhs >= rhs,
            OpKind::Lt => lhs < rhs,
            OpKind::Lte => lhs <= rhs,
            OpKind::Contains | OpKind::StartsWith | OpKind::In | OpKind::NotIn => false,
        };
    }

    // child.risk_tier — ordinal comparison against the proposed risk tier of the
    // child agent being spawned. Returns false when no spawn context is supplied.
    if let FieldRef::ChildRiskTier = field {
        let lhs = match policy_ctx.and_then(|c| c.child_risk_tier()) {
            Some(t) => t,
            None => return false,
        };
        let rhs = match literal {
            LiteralVal::Tier(t) => *t,
            _ => return false,
        };
        return match op {
            OpKind::Eq => lhs == rhs,
            OpKind::Ne => lhs != rhs,
            OpKind::Gt => lhs > rhs,
            OpKind::Gte => lhs >= rhs,
            OpKind::Lt => lhs < rhs,
            OpKind::Lte => lhs <= rhs,
            OpKind::Contains | OpKind::StartsWith | OpKind::In | OpKind::NotIn => false,
        };
    }

    // team.parallel_agents — integer count of currently-running agents in the current team.
    // Semantically equivalent to team.active_agents; delegates to the same registry query.
    // Returns false (null-safe no-match) when the agent has no team.
    if let FieldRef::TeamParallelAgents = field {
        let lhs = match policy_ctx.and_then(|c| c.team_active_agents()) {
            Some(n) => n as f64,
            None => return false,
        };
        let rhs = match numeric_literal(literal) {
            Some(r) => r,
            None => return false,
        };
        return match op {
            OpKind::Eq => lhs == rhs,
            OpKind::Ne => lhs != rhs,
            OpKind::Gt => lhs > rhs,
            OpKind::Gte => lhs >= rhs,
            OpKind::Lt => lhs < rhs,
            OpKind::Lte => lhs <= rhs,
            OpKind::Contains | OpKind::StartsWith | OpKind::In | OpKind::NotIn => false,
        };
    }

    // agent.age — numeric (seconds) comparison against the current agent's age since registration.
    // Compares the agent's age in seconds against a Duration literal (e.g. `24h` = 86400s).
    // Returns false (null-safe no-match) when context or registry lookup is absent.
    if let FieldRef::AgentAge = field {
        let lhs = match policy_ctx.and_then(|c| c.agent_age_secs()) {
            Some(age) => age as f64,
            None => return false,
        };
        let rhs = match numeric_literal(literal) {
            Some(r) => r,
            None => return false,
        };
        return match op {
            OpKind::Eq => lhs == rhs,
            OpKind::Ne => lhs != rhs,
            OpKind::Gt => lhs > rhs,
            OpKind::Gte => lhs >= rhs,
            OpKind::Lt => lhs < rhs,
            OpKind::Lte => lhs <= rhs,
            OpKind::Contains | OpKind::StartsWith | OpKind::In | OpKind::NotIn => false,
        };
    }

    // agent.parent_agent_id / agent.team_id — string comparison against an agent identity field.
    // Returns false when the field resolves to None (null-safe no-match).
    if matches!(field, FieldRef::AgentParentId | FieldRef::AgentTeamId) {
        let val = match field {
            FieldRef::AgentParentId => policy_ctx.and_then(|c| c.agent_parent_id()),
            FieldRef::AgentTeamId => policy_ctx.and_then(|c| c.agent_team_id()),
            _ => unreachable!(),
        };
        let id = match val {
            Some(v) => v,
            None => return false,
        };
        let rhs = match literal {
            LiteralVal::Str(s) => s.as_str(),
            _ => return false,
        };
        return match op {
            OpKind::Eq => id == rhs,
            OpKind::Ne => id != rhs,
            OpKind::Contains => id.contains(rhs),
            OpKind::StartsWith => id.starts_with(rhs),
            _ => false,
        };
    }

    // agent.children_count — numeric comparison against the number of direct children.
    // Returns false when the agent is not found in the registry (null-safe no-match).
    if let FieldRef::AgentChildrenCount = field {
        let lhs = match policy_ctx.and_then(|c| c.agent_children_count()) {
            Some(n) => n as f64,
            None => return false,
        };
        let rhs = match numeric_literal(literal) {
            Some(r) => r,
            None => return false,
        };
        return match op {
            OpKind::Eq => lhs == rhs,
            OpKind::Ne => lhs != rhs,
            OpKind::Gt => lhs > rhs,
            OpKind::Gte => lhs >= rhs,
            OpKind::Lt => lhs < rhs,
            OpKind::Lte => lhs <= rhs,
            OpKind::Contains | OpKind::StartsWith | OpKind::In | OpKind::NotIn => false,
        };
    }

    // agent.is_root / agent.is_leaf — boolean (0/1) topology flags.
    // is_root fires when depth == 0; is_leaf fires when children_count == 0.
    // Only Eq/Ne against numeric 1 or 0 are meaningful; other ops return false.
    // Returns false when the backing data is unavailable (null-safe no-match).
    if matches!(field, FieldRef::AgentIsRoot | FieldRef::AgentIsLeaf) {
        let flag: Option<bool> = match field {
            FieldRef::AgentIsRoot => policy_ctx.and_then(|c| c.agent_depth()).map(|d| d == 0),
            FieldRef::AgentIsLeaf => policy_ctx.and_then(|c| c.agent_children_count()).map(|n| n == 0),
            _ => unreachable!(),
        };
        let lhs = match flag {
            Some(v) => {
                if v {
                    1.0_f64
                } else {
                    0.0_f64
                }
            }
            None => return false,
        };
        let rhs = match numeric_literal(literal) {
            Some(r) => r,
            None => return false,
        };
        return match op {
            OpKind::Eq => lhs == rhs,
            OpKind::Ne => lhs != rhs,
            _ => false,
        };
    }

    // source.team_id — string equality against the sending agent's team ID.
    // Returns false (null-safe no-match) when the action is not SendMessage or
    // the source_team_id field is None.
    if let FieldRef::SourceTeamId = field {
        let team_id = match action {
            GovernanceAction::SendMessage { source_team_id, .. } => match source_team_id.as_deref() {
                Some(id) => id.to_owned(),
                None => return false,
            },
            _ => return false,
        };
        if *op == OpKind::In || *op == OpKind::NotIn {
            if let LiteralVal::StrList(list) = literal {
                return if *op == OpKind::In {
                    list.iter().any(|s| s == &team_id)
                } else {
                    !list.iter().any(|s| s == &team_id)
                };
            }
            return false;
        }
        let rhs = match literal {
            LiteralVal::Str(s) => s.as_str(),
            _ => return false,
        };
        return match op {
            OpKind::Eq => team_id == rhs,
            OpKind::Ne => team_id != rhs,
            OpKind::Contains => team_id.contains(rhs),
            OpKind::StartsWith => team_id.starts_with(rhs),
            _ => false,
        };
    }

    // target.team_id — string equality against the recipient team ID.
    // Returns false when the action is not SendMessage or target_team_id is None.
    if let FieldRef::TargetTeamId = field {
        let team_id = match action {
            GovernanceAction::SendMessage { target_team_id, .. } => match target_team_id.as_deref() {
                Some(id) => id.to_owned(),
                None => return false,
            },
            _ => return false,
        };
        if *op == OpKind::In || *op == OpKind::NotIn {
            if let LiteralVal::StrList(list) = literal {
                return if *op == OpKind::In {
                    list.iter().any(|s| s == &team_id)
                } else {
                    !list.iter().any(|s| s == &team_id)
                };
            }
            return false;
        }
        let rhs = match literal {
            LiteralVal::Str(s) => s.as_str(),
            _ => return false,
        };
        return match op {
            OpKind::Eq => team_id == rhs,
            OpKind::Ne => team_id != rhs,
            OpKind::Contains => team_id.contains(rhs),
            OpKind::StartsWith => team_id.starts_with(rhs),
            _ => false,
        };
    }

    // target.channel_id — string equality against the channel routing identifier.
    // Returns false when the action is not SendMessage or channel_id is None.
    if let FieldRef::TargetChannelId = field {
        let channel_id = match action {
            GovernanceAction::SendMessage { channel_id, .. } => match channel_id.as_deref() {
                Some(id) => id.to_owned(),
                None => return false,
            },
            _ => return false,
        };
        if *op == OpKind::In || *op == OpKind::NotIn {
            if let LiteralVal::StrList(list) = literal {
                return if *op == OpKind::In {
                    list.iter().any(|s| s == &channel_id)
                } else {
                    !list.iter().any(|s| s == &channel_id)
                };
            }
            return false;
        }
        let rhs = match literal {
            LiteralVal::Str(s) => s.as_str(),
            _ => return false,
        };
        return match op {
            OpKind::Eq => channel_id == rhs,
            OpKind::Ne => channel_id != rhs,
            OpKind::Contains => channel_id.contains(rhs),
            OpKind::StartsWith => channel_id.starts_with(rhs),
            _ => false,
        };
    }

    // governance_level is the only field whose value type is not a string;
    // route it through an Ord-based comparison and return early.
    if let FieldRef::GovernanceLevel = field {
        let rhs = match literal {
            LiteralVal::Level(l) => *l,
            // Mismatched literal kind (e.g. `governance_level == "L2"`) cannot
            // match — treat as no-fire rather than fail-safe approval, since
            // the validator should have rejected it before evaluation.
            _ => return false,
        };
        let lhs = match agent_level {
            Some(l) => l,
            // No agent level supplied → cannot compare; treat as no-match.
            None => return false,
        };
        return match op {
            OpKind::Eq => lhs == rhs,
            OpKind::Ne => lhs != rhs,
            OpKind::Gt => lhs > rhs,
            OpKind::Gte => lhs >= rhs,
            OpKind::Lt => lhs < rhs,
            OpKind::Lte => lhs <= rhs,
            // String operators do not apply to governance_level.
            OpKind::Contains | OpKind::StartsWith | OpKind::In | OpKind::NotIn => false,
        };
    }

    let lhs = field_value(field, action);

    match op {
        OpKind::Contains => {
            if let LiteralVal::Str(rhs) = literal {
                lhs.contains(rhs.as_str())
            } else {
                false
            }
        }
        OpKind::StartsWith => {
            if let LiteralVal::Str(rhs) = literal {
                lhs.starts_with(rhs.as_str())
            } else {
                false
            }
        }
        OpKind::In => {
            if let LiteralVal::StrList(list) = literal {
                list.iter().any(|s| s.as_str() == lhs)
            } else {
                false
            }
        }
        OpKind::NotIn => {
            if let LiteralVal::StrList(list) = literal {
                !list.iter().any(|s| s.as_str() == lhs)
            } else {
                false
            }
        }
        OpKind::Eq => match literal {
            LiteralVal::Num(rhs) => {
                if let Ok(lhs_num) = lhs.parse::<f64>() {
                    lhs_num == *rhs
                } else {
                    false
                }
            }
            LiteralVal::Str(rhs) => lhs == rhs.as_str(),
            // A level/tier/list/duration literal against a generic string field cannot match.
            LiteralVal::Level(_) | LiteralVal::Tier(_) | LiteralVal::StrList(_) | LiteralVal::Duration(_) => false,
        },
        OpKind::Ne => match literal {
            LiteralVal::Num(rhs) => {
                if let Ok(lhs_num) = lhs.parse::<f64>() {
                    lhs_num != *rhs
                } else {
                    true // can't parse as number, so not equal numerically
                }
            }
            LiteralVal::Str(rhs) => lhs != rhs.as_str(),
            // A level/tier/list/duration literal against a generic string field is unconditionally
            // not-equal — matches the symmetric `Eq` handling above.
            LiteralVal::Level(_) | LiteralVal::Tier(_) | LiteralVal::StrList(_) | LiteralVal::Duration(_) => true,
        },
        OpKind::Gt => {
            let rhs = numeric_literal(literal);
            let lhs_n = lhs.parse::<f64>().ok();
            match (lhs_n, rhs) {
                (Some(l), Some(r)) => l > r,
                _ => false,
            }
        }
        OpKind::Gte => {
            let rhs = numeric_literal(literal);
            let lhs_n = lhs.parse::<f64>().ok();
            match (lhs_n, rhs) {
                (Some(l), Some(r)) => l >= r,
                _ => false,
            }
        }
        OpKind::Lt => {
            let rhs = numeric_literal(literal);
            let lhs_n = lhs.parse::<f64>().ok();
            match (lhs_n, rhs) {
                (Some(l), Some(r)) => l < r,
                _ => false,
            }
        }
        OpKind::Lte => {
            let rhs = numeric_literal(literal);
            let lhs_n = lhs.parse::<f64>().ok();
            match (lhs_n, rhs) {
                (Some(l), Some(r)) => l <= r,
                _ => false,
            }
        }
    }
}

fn numeric_literal(lit: &LiteralVal) -> Option<f64> {
    match lit {
        LiteralVal::Num(n) => Some(*n),
        LiteralVal::Str(s) => s.parse::<f64>().ok(),
        // Duration participates in numeric comparisons (as seconds).
        LiteralVal::Duration(secs) => Some(*secs as f64),
        // Level, tier, and list literals never participate in numeric comparisons.
        LiteralVal::Level(_) | LiteralVal::Tier(_) | LiteralVal::StrList(_) => None,
    }
}

// ---------------------------------------------------------------------------
// Token evaluation  (AND binds tighter than OR)
// ---------------------------------------------------------------------------

/// A single parsed clause: `field op literal`.
struct Clause<'t> {
    field: &'t FieldRef,
    op: &'t OpKind,
    literal: &'t LiteralVal,
}

fn eval_tokens(
    tokens: &[Token],
    action: &GovernanceAction,
    agent_level: Option<GovernanceLevel>,
    policy_ctx: Option<&dyn PolicyContext>,
) -> bool {
    // Parse tokens into a sequence of clauses separated by AND/OR.
    // Strategy: split into OR-groups where each group is a slice of
    // AND-connected clauses.  Result = any OR-group where all clauses are true.

    // First, extract clauses and combinators in order.
    // Expected pattern: Clause (AND|OR Clause)*
    // A "Clause" is three consecutive tokens: Field, Op, Literal.

    let mut or_groups: Vec<Vec<Clause>> = vec![Vec::new()];

    let mut i = 0;
    while i < tokens.len() {
        // Expect: Field Op Literal
        match (&tokens[i], tokens.get(i + 1), tokens.get(i + 2)) {
            (Token::Field(f), Some(Token::Op(op)), Some(Token::Literal(lit))) => {
                let clause = Clause {
                    field: f,
                    op,
                    literal: lit,
                };
                or_groups.last_mut().unwrap().push(clause);
                i += 3;

                // Now expect AND | OR | end
                match tokens.get(i) {
                    None => break,
                    Some(Token::And) => {
                        i += 1; // continue in the same OR group
                    }
                    Some(Token::Or) => {
                        i += 1;
                        or_groups.push(Vec::new()); // start a new OR group
                    }
                    _ => return true, // unexpected token → fail-safe
                }
            }
            _ => return true, // unexpected structure → fail-safe
        }
    }

    // If nothing was parsed, that's a fail-safe trigger (empty expr)
    if or_groups.is_empty() || or_groups.iter().all(|g| g.is_empty()) {
        return true;
    }

    // Evaluate: OR across groups, AND within each group
    or_groups.iter().any(|group| {
        group
            .iter()
            .all(|c| eval_clause_safe(c.field, c.op, c.literal, action, agent_level, policy_ctx))
    })
}

// ---------------------------------------------------------------------------
// Variable extraction for load-time validation
// ---------------------------------------------------------------------------

/// Extract every identifier-like word from `expr` that could be a field
/// reference (skipping quoted strings, numeric literals, and combinators).
///
/// Used by [`validate_variables`] to find unknown variable names without
/// running the full tokenizer.
pub(crate) fn extract_field_names(expr: &str) -> Vec<String> {
    const SKIP_WORDS: &[&str] = &[
        "AND",
        "OR",
        "true",
        "false",
        "contains",
        "starts_with",
        "in",
        "not_in",
        "L0",
        "L1",
        "L2",
        "L3",
        "Low",
        "Medium",
        "High",
        "Critical",
    ];

    let mut names = Vec::new();
    let mut chars = expr.chars().peekable();

    while let Some(&ch) = chars.peek() {
        // Skip whitespace and operator chars
        if ch.is_whitespace() || matches!(ch, '<' | '>' | '=' | '!') {
            chars.next();
            continue;
        }

        // Skip list literals: [...] — contents are string values, not field names
        if ch == '[' {
            chars.next();
            loop {
                match chars.next() {
                    Some(']') | None => break,
                    _ => {}
                }
            }
            continue;
        }

        // Skip quoted string literals
        if ch == '"' {
            chars.next();
            loop {
                match chars.next() {
                    Some('"') | None => break,
                    Some('\\') => {
                        chars.next();
                    }
                    _ => {}
                }
            }
            continue;
        }

        // Collect word token (letters, digits, underscore, hyphen, dot)
        if ch.is_alphanumeric() || ch == '_' || ch == '-' || ch == '.' {
            let mut word = String::new();
            while let Some(&c) = chars.peek() {
                if c.is_alphanumeric() || c == '_' || c == '-' || c == '.' {
                    word.push(c);
                    chars.next();
                } else {
                    break;
                }
            }
            // Skip combinators, boolean keywords, and numeric literals
            if SKIP_WORDS.contains(&word.as_str()) || word.parse::<f64>().is_ok() {
                continue;
            }
            names.push(word);
            continue;
        }

        chars.next();
    }

    names
}

/// Return the closest entry in `KNOWN_VARIABLES` to `name` when the edit
/// distance is at most 2, or `None` when no candidate is close enough.
fn suggest_variable(name: &str) -> Option<&'static str> {
    KNOWN_VARIABLES
        .iter()
        .copied()
        .filter(|&v| strsim::levenshtein(name, v) <= 2)
        .min_by_key(|&v| strsim::levenshtein(name, v))
}

// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------

/// Validate that every identifier in `expr` is a member of [`KNOWN_VARIABLES`].
///
/// Returns [`PolicyParseError::UnknownVariable`] on the first unknown name
/// found, with a typo suggestion when the Levenshtein distance to the closest
/// known variable is ≤ 2.
pub(crate) fn validate_variables(expr: &str) -> Result<(), crate::policy::error::PolicyParseError> {
    for name in extract_field_names(expr) {
        // `args.<key>` / `args.<key>.<nested>` is a structural identifier
        // accepted by the lexer as `FieldRef::ToolArg`. There is no static
        // list of valid keys (they're inspected against the live action's
        // JSON-encoded args), so validation skips the membership check and
        // defers null-safety to the runtime evaluator.
        if name.starts_with("args.") && name.len() > "args.".len() {
            continue;
        }
        // Same shape applies to `tool_result.<key>` (response-side
        // FieldRef::ToolResult) and the bare `tool_result` shorthand
        // (FieldRef::ToolResultWhole) — neither sits in a static list.
        if name == "tool_result" || (name.starts_with("tool_result.") && name.len() > "tool_result.".len()) {
            continue;
        }
        if !KNOWN_VARIABLES.contains(&name.as_str()) {
            let suggestion = suggest_variable(&name).map(str::to_owned);
            let available = KNOWN_VARIABLES.iter().map(|s| s.to_string()).collect();
            return Err(crate::policy::error::PolicyParseError::UnknownVariable {
                name,
                suggestion,
                available,
            });
        }
    }
    Ok(())
}

/// Validate that every `governance_level` literal in `expr` is one of the
/// four known levels (L0..L3).
///
/// Returns the spec-mandated error message
/// `unknown governance level: <value>; valid values: L0, L1, L2, L3` when the
/// expression mentions an unknown level (e.g. `L4` or `LX`). Other shapes are
/// not rejected here — the runtime evaluator is fail-safe for everything else.
pub(crate) fn validate_governance_levels(expr: &str) -> Result<(), String> {
    let mut chars = expr.chars().peekable();
    while let Some(&ch) = chars.peek() {
        if ch == 'L' {
            // Collect the identifier-like word starting with 'L'.
            let mut word = String::new();
            while let Some(&c) = chars.peek() {
                if c.is_alphanumeric() || c == '_' {
                    word.push(c);
                    chars.next();
                } else {
                    break;
                }
            }
            // Only reject `L<digit>+` shapes — these are clearly intended as
            // level literals. Anything else (`Logger`, `Limit`, …) is left
            // for the runtime tokenizer to handle.
            let rest = &word[1..];
            if !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()) {
                match word.as_str() {
                    "L0" | "L1" | "L2" | "L3" => {}
                    _ => {
                        return Err(format!(
                            "unknown governance level: {word}; valid values: L0, L1, L2, L3"
                        ));
                    }
                }
            }
            continue;
        }
        chars.next();
    }
    Ok(())
}

/// Evaluate a flat boolean expression against a [`GovernanceAction`] and the
/// governing agent's [`GovernanceLevel`].
///
/// `agent_level` is consulted only by clauses referencing the
/// `governance_level` field; pass `None` when the caller does not know the
/// agent's level (e.g. legacy code paths) — clauses that depend on the
/// level are then treated as unknown comparisons (no-match).
///
/// Returns `true` if the expression matches (approval required).
/// Returns `true` on ANY parse/tokenization error (fail-safe).
pub(crate) fn evaluate(
    expr: &str,
    action: &GovernanceAction,
    agent_level: Option<GovernanceLevel>,
    policy_ctx: Option<&dyn PolicyContext>,
) -> bool {
    let tokens = match tokenize(expr) {
        Some(t) if !t.is_empty() => t,
        _ => return true, // fail-safe
    };
    eval_tokens(&tokens, action, agent_level, policy_ctx)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use aa_core::{FileMode, GovernanceAction};

    fn tool(name: &str) -> GovernanceAction {
        GovernanceAction::ToolCall {
            name: name.to_string(),
            args: String::new(),
        }
    }

    /// Build a `ToolCall` whose `args` is a JSON-encoded body — used by the
    /// `args.<key>` predicate tests below.
    fn tool_with_args(name: &str, args: &str) -> GovernanceAction {
        GovernanceAction::ToolCall {
            name: name.to_string(),
            args: args.to_string(),
        }
    }

    fn file(path: &str) -> GovernanceAction {
        GovernanceAction::FileAccess {
            path: path.to_string(),
            mode: FileMode::Read,
        }
    }

    fn network(url: &str, method: &str) -> GovernanceAction {
        GovernanceAction::NetworkRequest {
            url: url.to_string(),
            method: method.to_string(),
        }
    }

    fn process(command: &str) -> GovernanceAction {
        GovernanceAction::ProcessExec {
            command: command.to_string(),
        }
    }

    #[test]
    fn eq_operator_matches_tool_name() {
        assert!(evaluate(r#"tool == "search""#, &tool("search"), None, None));
    }

    #[test]
    fn ne_operator_false_when_equal() {
        assert!(!evaluate(r#"tool != "search""#, &tool("search"), None, None));
    }

    #[test]
    fn contains_operator_on_url() {
        assert!(evaluate(
            r#"url contains "evil""#,
            &network("https://evil.com", "GET"),
            None,
            None,
        ));
    }

    #[test]
    fn starts_with_operator_on_path() {
        assert!(evaluate(r#"path starts_with "/etc""#, &file("/etc/passwd"), None, None));
    }

    #[test]
    fn and_combinator_all_true() {
        assert!(evaluate(
            r#"tool == "search" AND tool == "search""#,
            &tool("search"),
            None,
            None,
        ));
    }

    #[test]
    fn and_combinator_short_circuits() {
        assert!(!evaluate(
            r#"tool == "search" AND tool == "other""#,
            &tool("search"),
            None,
            None,
        ));
    }

    #[test]
    fn or_combinator_first_true() {
        assert!(evaluate(
            r#"tool == "x" OR tool == "search""#,
            &tool("search"),
            None,
            None
        ));
    }

    #[test]
    fn fail_safe_on_bad_expr() {
        assert!(evaluate("not valid @@@ expr", &tool("anything"), None, None));
    }

    #[test]
    fn field_absent_for_action_variant_returns_false() {
        // `tool` field is "" for ProcessExec → should NOT match "foo"
        assert!(!evaluate(r#"tool == "foo""#, &process("ls"), None, None));
    }

    #[test]
    fn rule_with_ge_l2_fires_for_l2_agent() {
        // An L2 agent satisfies `governance_level >= L2`.
        assert!(evaluate(
            "governance_level >= L2",
            &tool("any"),
            Some(GovernanceLevel::L2Enforce),
            None,
        ));
    }

    #[test]
    fn rule_with_ge_l2_does_not_fire_for_l1_agent() {
        // An L1 agent does not satisfy `governance_level >= L2`.
        assert!(!evaluate(
            "governance_level >= L2",
            &tool("any"),
            Some(GovernanceLevel::L1Observe),
            None,
        ));
    }

    #[test]
    fn rule_without_level_condition_fires_for_all_levels() {
        // Backward compat: a condition that does not mention
        // `governance_level` evaluates the same way at every level.
        for level in [
            GovernanceLevel::L0Discover,
            GovernanceLevel::L1Observe,
            GovernanceLevel::L2Enforce,
            GovernanceLevel::L3Native,
        ] {
            assert!(
                evaluate(r#"tool == "search""#, &tool("search"), Some(level), None),
                "tool-only condition unexpectedly skipped for {level:?}"
            );
        }
    }

    fn fake_ctx(depth: Option<u32>) -> crate::policy::context::FakePolicyContext {
        crate::policy::context::FakePolicyContext {
            depth,
            ..Default::default()
        }
    }

    #[test]
    fn agent_depth_gt_matches_when_deeper() {
        let ctx = fake_ctx(Some(3));
        assert!(evaluate("agent.depth > 2", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn agent_depth_gt_no_match_when_shallower() {
        let ctx = fake_ctx(Some(1));
        assert!(!evaluate("agent.depth > 2", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn agent_depth_eq_matches_exact() {
        let ctx = fake_ctx(Some(0));
        assert!(evaluate("agent.depth == 0", &tool("any"), None, Some(&ctx)));
    }

    fn fake_team_ctx(active: Option<u64>) -> crate::policy::context::FakePolicyContext {
        crate::policy::context::FakePolicyContext {
            team_active: active,
            ..Default::default()
        }
    }

    #[test]
    fn team_active_agents_gt_matches() {
        let ctx = fake_team_ctx(Some(6));
        assert!(evaluate("team.active_agents > 5", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn team_active_agents_gt_no_match() {
        let ctx = fake_team_ctx(Some(3));
        assert!(!evaluate("team.active_agents > 5", &tool("any"), None, Some(&ctx)));
    }

    fn fake_budget_ctx(remaining: Option<f64>) -> crate::policy::context::FakePolicyContext {
        crate::policy::context::FakePolicyContext {
            team_budget: remaining,
            ..Default::default()
        }
    }

    #[test]
    fn team_budget_remaining_lt_matches_when_low() {
        let ctx = fake_budget_ctx(Some(50.0));
        assert!(evaluate("team.budget_remaining < 100", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn team_budget_remaining_lt_no_match_when_sufficient() {
        let ctx = fake_budget_ctx(Some(200.0));
        assert!(!evaluate("team.budget_remaining < 100", &tool("any"), None, Some(&ctx)));
    }

    fn fake_child_ctx(tools: Vec<&str>) -> crate::policy::context::FakePolicyContext {
        crate::policy::context::FakePolicyContext {
            child_tools: tools.into_iter().map(String::from).collect(),
            ..Default::default()
        }
    }

    #[test]
    fn child_tool_eq_matches_when_present() {
        let ctx = fake_child_ctx(vec!["bash", "search"]);
        assert!(evaluate(r#"child.tool == "bash""#, &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn child_tool_eq_no_match_when_absent() {
        let ctx = fake_child_ctx(vec!["search"]);
        assert!(!evaluate(r#"child.tool == "bash""#, &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn child_tool_ne_true_when_all_differ() {
        let ctx = fake_child_ctx(vec!["search"]);
        assert!(evaluate(r#"child.tool != "bash""#, &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn null_safety_team_active_returns_false_when_no_team() {
        // team_active = None means the agent has no team; condition must not fire.
        let ctx = crate::policy::context::FakePolicyContext::default();
        assert!(!evaluate("team.active_agents > 0", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn null_safety_returns_false_when_no_context() {
        // No context at all: graph-aware field must not fire (fail-closed → no-match).
        assert!(!evaluate("agent.depth > 0", &tool("any"), None, None));
    }

    // ── risk tier tests ──────────────────────────────────────────────────

    fn fake_tier_ctx(
        agent: Option<aa_core::RiskTier>,
        parent: Option<aa_core::RiskTier>,
    ) -> crate::policy::context::FakePolicyContext {
        crate::policy::context::FakePolicyContext {
            agent_risk_tier: agent,
            parent_risk_tier: parent,
            ..Default::default()
        }
    }

    #[test]
    fn agent_risk_tier_eq_matches_same_tier() {
        let ctx = fake_tier_ctx(Some(aa_core::RiskTier::Medium), None);
        assert!(evaluate("agent.risk_tier == Medium", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn agent_risk_tier_eq_no_match_different_tier() {
        let ctx = fake_tier_ctx(Some(aa_core::RiskTier::Low), None);
        assert!(!evaluate("agent.risk_tier == Medium", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn agent_risk_tier_gt_detects_escalation() {
        let ctx = fake_tier_ctx(Some(aa_core::RiskTier::High), Some(aa_core::RiskTier::Medium));
        // agent is High, parent is Medium → child tier > parent tier
        assert!(evaluate("agent.risk_tier > Medium", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn parent_risk_tier_eq_matches() {
        let ctx = fake_tier_ctx(Some(aa_core::RiskTier::High), Some(aa_core::RiskTier::Medium));
        assert!(evaluate("parent.risk_tier == Medium", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn parent_risk_tier_returns_false_when_no_parent() {
        let ctx = fake_tier_ctx(Some(aa_core::RiskTier::Low), None);
        assert!(!evaluate("parent.risk_tier == Low", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn risk_tier_null_safe_no_context() {
        assert!(!evaluate("agent.risk_tier == High", &tool("any"), None, None));
    }

    // ── child.risk_tier tests ────────────────────────────────────────────────

    fn fake_child_tier_ctx(child: Option<aa_core::RiskTier>) -> crate::policy::context::FakePolicyContext {
        crate::policy::context::FakePolicyContext {
            child_risk_tier: child,
            ..Default::default()
        }
    }

    #[test]
    fn child_risk_tier_gt_denies_escalation() {
        // Spawn proposes High; parent is Medium → child.risk_tier > Medium fires.
        let ctx = fake_child_tier_ctx(Some(aa_core::RiskTier::High));
        assert!(evaluate("child.risk_tier > Medium", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn child_risk_tier_same_tier_does_not_fire() {
        // Spawn proposes Medium; parent is Medium → child.risk_tier > Medium does not fire.
        let ctx = fake_child_tier_ctx(Some(aa_core::RiskTier::Medium));
        assert!(!evaluate("child.risk_tier > Medium", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn child_risk_tier_eq_matches_exact() {
        let ctx = fake_child_tier_ctx(Some(aa_core::RiskTier::Critical));
        assert!(evaluate("child.risk_tier == Critical", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn child_risk_tier_null_safe_when_no_spawn_context() {
        // No spawn context supplied → condition does not fire (null-safe no-match).
        let ctx = fake_child_tier_ctx(None);
        assert!(!evaluate("child.risk_tier > Low", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn child_risk_tier_null_safe_no_context_at_all() {
        assert!(!evaluate("child.risk_tier == High", &tool("any"), None, None));
    }

    // ── validate_variables tests ──────────────────────────────────────────

    #[test]
    fn validate_variables_accepts_known_variable() {
        assert!(validate_variables("agent.depth > 2").is_ok());
        assert!(validate_variables("team.active_agents == 5").is_ok());
        assert!(validate_variables("child.tool == \"bash\"").is_ok());
        assert!(validate_variables("child.risk_tier > Medium").is_ok());
        assert!(validate_variables("child.risk_tier == Critical").is_ok());
    }

    #[test]
    fn validate_variables_rejects_unknown_variable() {
        let err = validate_variables("agent.xyz > 0").unwrap_err();
        let msg = err.to_string();
        assert!(msg.contains("agent.xyz"), "message should name the unknown var: {msg}");
        assert!(msg.contains("agent.depth"), "message should list known vars: {msg}");
    }

    #[test]
    fn validate_variables_suggests_typo_correction() {
        let err = validate_variables("agent.depht > 0").unwrap_err();
        match err {
            crate::policy::error::PolicyParseError::UnknownVariable { name, suggestion, .. } => {
                assert_eq!(name, "agent.depht");
                assert_eq!(suggestion.as_deref(), Some("agent.depth"));
            }
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[test]
    fn validate_variables_no_suggestion_when_too_different() {
        let err = validate_variables("completely_unknown > 0").unwrap_err();
        match err {
            crate::policy::error::PolicyParseError::UnknownVariable { suggestion, .. } => {
                assert!(
                    suggestion.is_none(),
                    "should not suggest a match for a very different name"
                );
            }
            other => panic!("unexpected error: {other:?}"),
        }
    }

    // ── agent.age and team.parallel_agents tests ─────────────────────────────

    fn fake_age_ctx(age_secs: Option<u64>) -> crate::policy::context::FakePolicyContext {
        crate::policy::context::FakePolicyContext {
            agent_age_secs: age_secs,
            ..Default::default()
        }
    }

    #[test]
    fn agent_age_gt_24h_fires_when_agent_is_old() {
        // 25 hours old → 90000 s; rule threshold is 24h = 86400 s
        let ctx = fake_age_ctx(Some(90_000));
        assert!(evaluate("agent.age > 24h", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn agent_age_gt_24h_no_match_when_agent_is_young() {
        // 10 hours old → 36000 s; does not exceed 24h
        let ctx = fake_age_ctx(Some(36_000));
        assert!(!evaluate("agent.age > 24h", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn team_parallel_agents_gt_matches() {
        let ctx = crate::policy::context::FakePolicyContext {
            team_active: Some(8),
            ..Default::default()
        };
        assert!(evaluate("team.parallel_agents > 5", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn null_safety_agent_age_returns_false_without_context() {
        assert!(!evaluate("agent.age > 24h", &tool("any"), None, None));
    }

    // ── inter-team message condition tests ───────────────────────────────────

    fn send_message(source: Option<&str>, target: Option<&str>, channel: Option<&str>) -> GovernanceAction {
        GovernanceAction::SendMessage {
            source_team_id: source.map(String::from),
            target_team_id: target.map(String::from),
            channel_id: channel.map(String::from),
        }
    }

    #[test]
    fn source_team_id_eq_matches_same_team_message() {
        let msg = send_message(Some("team-alpha"), Some("team-beta"), Some("ops"));
        assert!(evaluate(r#"source.team_id == "team-alpha""#, &msg, None, None));
    }

    #[test]
    fn target_team_id_and_channel_id_eq_match_cross_team_message() {
        let msg = send_message(Some("team-alpha"), Some("team-beta"), Some("ops"));
        assert!(evaluate(r#"target.team_id == "team-beta""#, &msg, None, None));
        assert!(evaluate(r#"target.channel_id == "ops""#, &msg, None, None));
    }

    #[test]
    fn channel_id_eq_no_match_when_different_channel() {
        let msg = send_message(Some("team-alpha"), Some("team-beta"), Some("ops"));
        assert!(!evaluate(r#"target.channel_id == "dev""#, &msg, None, None));
    }

    #[test]
    fn null_safety_non_message_action_returns_false_for_message_fields() {
        // A ToolCall is not a SendMessage; message field conditions must not fire.
        assert!(!evaluate(r#"source.team_id == "team-alpha""#, &tool("any"), None, None));
        assert!(!evaluate(r#"target.team_id == "team-beta""#, &tool("any"), None, None));
        assert!(!evaluate(r#"target.channel_id == "ops""#, &tool("any"), None, None));
    }

    #[test]
    fn null_safety_none_fields_in_send_message_return_false() {
        // All three fields are None → conditions must not fire (null-safe no-match).
        let msg = send_message(None, None, None);
        assert!(!evaluate(r#"source.team_id == "team-alpha""#, &msg, None, None));
        assert!(!evaluate(r#"target.team_id == "team-beta""#, &msg, None, None));
        assert!(!evaluate(r#"target.channel_id == "ops""#, &msg, None, None));
    }

    // ── in / not_in operator tests ────────────────────────────────────────────

    #[test]
    fn channel_id_in_list_matches_when_present() {
        let msg = send_message(Some("team-alpha"), Some("team-beta"), Some("ops"));
        assert!(evaluate(r#"target.channel_id in ["ops", "general"]"#, &msg, None, None));
    }

    #[test]
    fn channel_id_in_list_no_match_when_absent() {
        let msg = send_message(Some("team-alpha"), Some("team-beta"), Some("private"));
        assert!(!evaluate(
            r#"target.channel_id in ["ops", "general"]"#,
            &msg,
            None,
            None
        ));
    }

    #[test]
    fn channel_id_not_in_list_no_match_when_in_list() {
        let msg = send_message(Some("team-alpha"), Some("team-beta"), Some("ops"));
        assert!(!evaluate(
            r#"target.channel_id not_in ["ops", "general"]"#,
            &msg,
            None,
            None
        ));
    }

    #[test]
    fn channel_id_not_in_list_matches_when_not_in_list() {
        let msg = send_message(Some("team-alpha"), Some("team-beta"), Some("private"));
        assert!(evaluate(
            r#"target.channel_id not_in ["ops", "general"]"#,
            &msg,
            None,
            None
        ));
    }

    #[test]
    fn source_team_id_in_list_matches_known_team() {
        let msg = send_message(Some("team-alpha"), Some("team-beta"), Some("ops"));
        assert!(evaluate(
            r#"source.team_id in ["team-alpha", "team-gamma"]"#,
            &msg,
            None,
            None
        ));
    }

    #[test]
    fn target_team_id_not_in_allows_non_restricted_team() {
        let msg = send_message(Some("team-alpha"), Some("team-beta"), Some("ops"));
        assert!(evaluate(
            r#"target.team_id not_in ["team-restricted"]"#,
            &msg,
            None,
            None
        ));
    }

    #[test]
    fn in_operator_null_safe_for_non_message_action() {
        // ToolCall is not a SendMessage; channel_id resolves to None → false.
        assert!(!evaluate(r#"target.channel_id in ["ops"]"#, &tool("any"), None, None));
        assert!(!evaluate(
            r#"target.channel_id not_in ["ops"]"#,
            &tool("any"),
            None,
            None
        ));
    }

    #[test]
    fn in_list_with_empty_list_never_matches() {
        let msg = send_message(Some("team-alpha"), Some("team-beta"), Some("ops"));
        assert!(!evaluate(r#"target.channel_id in []"#, &msg, None, None));
        assert!(evaluate(r#"target.channel_id not_in []"#, &msg, None, None));
    }

    #[test]
    fn validate_variables_suggests_source_team_id_for_typo() {
        let err = validate_variables(r#"source.team_d == "team-alpha""#).unwrap_err();
        match err {
            crate::policy::error::PolicyParseError::UnknownVariable { name, suggestion, .. } => {
                assert_eq!(name, "source.team_d");
                assert_eq!(suggestion.as_deref(), Some("source.team_id"));
            }
            other => panic!("unexpected error: {other:?}"),
        }
    }

    #[test]
    fn parser_accepts_l0_through_l3() {
        // Each named level parses and compares equal against an agent of the
        // same level — covering all four members of the `GovernanceLevel`
        // enum in a single test.
        for (literal, level) in [
            ("L0", GovernanceLevel::L0Discover),
            ("L1", GovernanceLevel::L1Observe),
            ("L2", GovernanceLevel::L2Enforce),
            ("L3", GovernanceLevel::L3Native),
        ] {
            let expr = format!("governance_level == {literal}");
            assert!(
                evaluate(&expr, &tool("any"), Some(level), None),
                "{literal} did not parse / compare equal for matching agent level"
            );
        }
    }

    // ── agent.parent_id, agent.team_id, agent.children_count tests ──────────

    fn fake_topology_ctx(
        parent_id: Option<&str>,
        team_id: Option<&str>,
        children_count: Option<u32>,
    ) -> crate::policy::context::FakePolicyContext {
        crate::policy::context::FakePolicyContext {
            agent_parent_id: parent_id.map(String::from),
            agent_team_id: team_id.map(String::from),
            agent_children_count: children_count,
            ..Default::default()
        }
    }

    #[test]
    fn agent_parent_id_eq_matches_known_parent() {
        let ctx = fake_topology_ctx(Some("parent-abc"), None, None);
        assert!(evaluate(
            r#"agent.parent_agent_id == "parent-abc""#,
            &tool("any"),
            None,
            Some(&ctx)
        ));
    }

    #[test]
    fn agent_parent_id_eq_no_match_different_parent() {
        let ctx = fake_topology_ctx(Some("parent-xyz"), None, None);
        assert!(!evaluate(
            r#"agent.parent_agent_id == "parent-abc""#,
            &tool("any"),
            None,
            Some(&ctx)
        ));
    }

    #[test]
    fn agent_parent_id_null_safe_when_no_parent() {
        let ctx = fake_topology_ctx(None, None, None);
        assert!(!evaluate(
            r#"agent.parent_agent_id == "parent-abc""#,
            &tool("any"),
            None,
            Some(&ctx)
        ));
    }

    #[test]
    fn agent_team_id_eq_matches_known_team() {
        let ctx = fake_topology_ctx(None, Some("team-alpha"), None);
        assert!(evaluate(
            r#"agent.team_id == "team-alpha""#,
            &tool("any"),
            None,
            Some(&ctx)
        ));
    }

    #[test]
    fn agent_team_id_eq_no_match_different_team() {
        let ctx = fake_topology_ctx(None, Some("team-beta"), None);
        assert!(!evaluate(
            r#"agent.team_id == "team-alpha""#,
            &tool("any"),
            None,
            Some(&ctx)
        ));
    }

    #[test]
    fn agent_team_id_null_safe_when_no_team() {
        let ctx = fake_topology_ctx(None, None, None);
        assert!(!evaluate(
            r#"agent.team_id == "team-alpha""#,
            &tool("any"),
            None,
            Some(&ctx)
        ));
    }

    #[test]
    fn agent_children_count_gt_matches_when_has_children() {
        let ctx = fake_topology_ctx(None, None, Some(3));
        assert!(evaluate("agent.children_count > 0", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn agent_children_count_eq_zero_matches_leaf() {
        let ctx = fake_topology_ctx(None, None, Some(0));
        assert!(evaluate("agent.children_count == 0", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn agent_children_count_null_safe_without_context() {
        assert!(!evaluate("agent.children_count > 0", &tool("any"), None, None));
    }

    // ── agent.is_root, agent.is_leaf tests ───────────────────────────────────

    fn fake_depth_children_ctx(depth: Option<u32>, children: Option<u32>) -> crate::policy::context::FakePolicyContext {
        crate::policy::context::FakePolicyContext {
            depth,
            agent_children_count: children,
            ..Default::default()
        }
    }

    #[test]
    fn agent_is_root_eq_1_matches_root_agent() {
        let ctx = fake_depth_children_ctx(Some(0), None);
        assert!(evaluate("agent.is_root == 1", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn agent_is_root_eq_1_no_match_non_root_agent() {
        let ctx = fake_depth_children_ctx(Some(2), None);
        assert!(!evaluate("agent.is_root == 1", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn agent_is_root_null_safe_without_context() {
        assert!(!evaluate("agent.is_root == 1", &tool("any"), None, None));
    }

    #[test]
    fn agent_is_leaf_eq_1_matches_agent_with_no_children() {
        let ctx = fake_depth_children_ctx(None, Some(0));
        assert!(evaluate("agent.is_leaf == 1", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn agent_is_leaf_eq_1_no_match_agent_with_children() {
        let ctx = fake_depth_children_ctx(None, Some(2));
        assert!(!evaluate("agent.is_leaf == 1", &tool("any"), None, Some(&ctx)));
    }

    #[test]
    fn agent_is_leaf_null_safe_without_context() {
        assert!(!evaluate("agent.is_leaf == 1", &tool("any"), None, None));
    }

    #[test]
    fn validate_variables_accepts_new_topology_variables() {
        assert!(validate_variables(r#"agent.parent_agent_id == "abc""#).is_ok());
        assert!(validate_variables(r#"agent.team_id == "t1""#).is_ok());
        assert!(validate_variables("agent.children_count > 0").is_ok());
        assert!(validate_variables("agent.is_root == 1").is_ok());
        assert!(validate_variables("agent.is_leaf == 1").is_ok());
    }

    // ── compound AND rule: equivalent of AAASM-225 example rule 3 ───────────
    //
    // Original rule 3 used `action.model_cost * 0.2` which requires arithmetic
    // not supported by the flat grammar. The equivalent supported form combines
    // agent.depth and team.budget_remaining with AND — testing the same
    // compound-condition path.

    fn fake_depth_budget_ctx(depth: Option<u32>, budget: Option<f64>) -> crate::policy::context::FakePolicyContext {
        crate::policy::context::FakePolicyContext {
            depth,
            team_budget: budget,
            ..Default::default()
        }
    }

    #[test]
    fn compound_and_depth_budget_fires_when_both_clauses_true() {
        // depth=2 satisfies `agent.depth > 0`; budget=50 satisfies `team.budget_remaining < 100`
        let ctx = fake_depth_budget_ctx(Some(2), Some(50.0));
        assert!(evaluate(
            "agent.depth > 0 AND team.budget_remaining < 100",
            &tool("any"),
            None,
            Some(&ctx),
        ));
    }

    #[test]
    fn compound_and_depth_budget_no_fire_when_depth_clause_false() {
        // depth=0 fails `agent.depth > 0`; AND short-circuits → no fire
        let ctx = fake_depth_budget_ctx(Some(0), Some(50.0));
        assert!(!evaluate(
            "agent.depth > 0 AND team.budget_remaining < 100",
            &tool("any"),
            None,
            Some(&ctx),
        ));
    }

    #[test]
    fn compound_and_depth_budget_no_fire_when_budget_clause_false() {
        // depth=2 satisfies first clause; budget=200 fails `team.budget_remaining < 100` → no fire
        let ctx = fake_depth_budget_ctx(Some(2), Some(200.0));
        assert!(!evaluate(
            "agent.depth > 0 AND team.budget_remaining < 100",
            &tool("any"),
            None,
            Some(&ctx),
        ));
    }

    // ── FieldRef::ToolArg — args.<key> predicate tests ──────────────────────

    #[test]
    fn args_field_eq_matches_string_value() {
        let action = tool_with_args("read_file", r#"{"path": "/etc/passwd"}"#);
        assert!(evaluate(r#"args.path == "/etc/passwd""#, &action, None, None));
    }

    #[test]
    fn args_starts_with_matches_etc_path_prefix() {
        // The flagship AAASM-1930 ST-Q-1 predicate shape.
        let action = tool_with_args("read_file", r#"{"path": "/etc/passwd"}"#);
        assert!(evaluate(r#"args.path starts_with "/etc""#, &action, None, None));
    }

    #[test]
    fn args_starts_with_no_match_outside_etc_prefix() {
        // The negative side of the same rule: a path the policy should allow.
        let action = tool_with_args("read_file", r#"{"path": "/home/user/file.txt"}"#);
        assert!(!evaluate(r#"args.path starts_with "/etc""#, &action, None, None));
    }

    #[test]
    fn args_walks_nested_json_pointer() {
        // `args.headers.authorization` → JSON pointer "/headers/authorization".
        let action = tool_with_args("http_fetch", r#"{"headers": {"authorization": "Bearer abc"}}"#);
        assert!(evaluate(
            r#"args.headers.authorization starts_with "Bearer""#,
            &action,
            None,
            None,
        ));
    }

    #[test]
    fn args_missing_key_is_null_safe_no_match() {
        // Pointer doesn't resolve → no-match (NOT a fail-safe true).
        let action = tool_with_args("read_file", r#"{"other": "value"}"#);
        assert!(!evaluate(r#"args.path == "/etc/passwd""#, &action, None, None));
        assert!(!evaluate(r#"args.path starts_with "/etc""#, &action, None, None));
    }

    #[test]
    fn args_malformed_json_is_null_safe_no_match() {
        // Args body that doesn't parse as JSON (default empty string, garbage,
        // truncated, etc.) is treated as null-safe no-match — policies don't
        // fire and policies don't fail-safe `true` either.
        let empty = tool_with_args("read_file", "");
        assert!(!evaluate(r#"args.path == "/etc/passwd""#, &empty, None, None));

        let garbage = tool_with_args("read_file", "{not valid json");
        assert!(!evaluate(r#"args.path == "/etc/passwd""#, &garbage, None, None));
    }

    #[test]
    fn args_in_list_matches_when_value_is_member() {
        let action = tool_with_args("invoke", r#"{"action": "delete"}"#);
        assert!(evaluate(
            r#"args.action in ["delete", "drop", "truncate"]"#,
            &action,
            None,
            None,
        ));
    }

    #[test]
    fn args_not_in_list_matches_when_value_outside_allowlist() {
        // The allowlist shape: deny when args.action is not in the allowed set.
        let action = tool_with_args("invoke", r#"{"action": "execute_bash"}"#);
        assert!(evaluate(r#"args.action not_in ["read", "write"]"#, &action, None, None,));
    }

    #[test]
    fn args_numeric_comparison_against_json_number() {
        // Numeric ops (`>`, `>=`, `<`, `<=`) work against JSON number values.
        let action = tool_with_args("rpc_call", r#"{"timeout_ms": 30000}"#);
        assert!(evaluate("args.timeout_ms > 1000", &action, None, None));
        assert!(evaluate("args.timeout_ms <= 30000", &action, None, None));
        assert!(!evaluate("args.timeout_ms < 100", &action, None, None));
    }

    #[test]
    fn args_predicate_against_non_toolcall_action_is_no_match() {
        // A FileAccess action carries no `args` payload; the same expression
        // that fires on a ToolCall must surface as no-match for non-ToolCall
        // variants so policies don't leak across action types.
        assert!(!evaluate(
            r#"args.path starts_with "/etc""#,
            &file("/etc/passwd"),
            None,
            None
        ));
        assert!(!evaluate(r#"args.path starts_with "/etc""#, &process("ls"), None, None));
        assert!(!evaluate(
            r#"args.path starts_with "/etc""#,
            &network("https://example.com", "GET"),
            None,
            None,
        ));
    }

    // ── FieldRef::ToolResult — tool_result.<key> + bare tool_result tests ───

    /// Build a `ToolResult` action against a known `tool_name` whose `result`
    /// is a JSON-encoded body — used by the response-side predicate tests
    /// below.
    fn tool_result_with_body(tool_name: &str, body: &str) -> GovernanceAction {
        GovernanceAction::ToolResult {
            tool_name: tool_name.to_string(),
            result: body.to_string(),
        }
    }

    #[test]
    fn tool_result_field_eq_matches_string_leaf() {
        let action = tool_result_with_body("read_file", r#"{"contents": "hello"}"#);
        assert!(evaluate(r#"tool_result.contents == "hello""#, &action, None, None));
    }

    #[test]
    fn tool_result_field_contains_matches_substring_in_leaf() {
        // The ST-Q-3 acceptance criterion's predicate shape — a credential
        // pattern fragment inside a nested string field.
        let action = tool_result_with_body("read_file", r#"{"contents": "key=sk-abc123"}"#);
        assert!(evaluate(r#"tool_result.contents contains "sk-""#, &action, None, None));
    }

    #[test]
    fn tool_result_walks_nested_json_pointer() {
        // `tool_result.payload.api_key` → JSON pointer "/payload/api_key".
        let action = tool_result_with_body("http_fetch", r#"{"payload": {"api_key": "sk-test-xyz"}}"#);
        assert!(evaluate(
            r#"tool_result.payload.api_key starts_with "sk-""#,
            &action,
            None,
            None,
        ));
    }

    #[test]
    fn bare_tool_result_contains_pattern_in_whole_body() {
        // The ST-Q-3 shorthand: `tool_result contains "sk-"` matches against
        // the entire serialised result body — useful when the schema is
        // unknown but the secret prefix is.
        let action = tool_result_with_body(
            "search",
            r#"{"items": [{"snippet": "leaked key: sk-test-123 in log"}]}"#,
        );
        assert!(evaluate(r#"tool_result contains "sk-""#, &action, None, None));
    }

    #[test]
    fn bare_tool_result_starts_with_against_whole_body() {
        // Whole-body starts_with is rarer but sometimes useful — e.g. asserting
        // a response is JSON-shaped (`{...}`) before any further predicate runs.
        let action = tool_result_with_body("ping", r#"{"ok": true}"#);
        assert!(evaluate(r#"tool_result starts_with "{""#, &action, None, None));
    }

    #[test]
    fn tool_result_missing_key_is_null_safe_no_match() {
        // The pointer fails to resolve — predicate must be no-match, NOT
        // fail-safe true.
        let action = tool_result_with_body("read_file", r#"{"other": "value"}"#);
        assert!(!evaluate(r#"tool_result.contents == "hello""#, &action, None, None,));
        assert!(!evaluate(
            r#"tool_result.contents starts_with "h""#,
            &action,
            None,
            None,
        ));
    }

    #[test]
    fn tool_result_malformed_json_is_null_safe_no_match() {
        // A result body that doesn't parse as JSON (default empty string,
        // truncated, etc.) is no-match for dotted predicates — but the bare
        // `tool_result contains "..."` still matches the raw bytes because
        // it never parses the body.
        let empty = tool_result_with_body("read_file", "");
        assert!(!evaluate(r#"tool_result.contents == "hello""#, &empty, None, None,));

        let garbage = tool_result_with_body("read_file", "{not valid json");
        assert!(!evaluate(r#"tool_result.contents == "hello""#, &garbage, None, None,));
        assert!(evaluate(r#"tool_result contains "not valid""#, &garbage, None, None));
    }

    #[test]
    fn tool_result_numeric_comparison_against_json_number() {
        // Numeric ops (`>`, `>=`, `<`, `<=`, ==, !=) work against JSON
        // numbers — mirror of args.<key>'s numeric path so policies can
        // gate on response-side numeric fields (counts, sizes, scores).
        let action = tool_result_with_body("score", r#"{"score": 95}"#);
        assert!(evaluate("tool_result.score > 90", &action, None, None));
        assert!(evaluate("tool_result.score <= 95", &action, None, None));
        assert!(!evaluate("tool_result.score < 50", &action, None, None));
        assert!(evaluate("tool_result.score == 95", &action, None, None));
    }

    #[test]
    fn tool_result_predicate_against_non_toolresult_action_is_no_match() {
        // A ToolCall / FileAccess / NetworkRequest / ProcessExec action carries
        // no `result` payload; tool_result predicates must surface as no-match
        // so policies don't leak across request/response sides. The same shape
        // applies to the bare `tool_result` whole-body predicate.
        assert!(!evaluate(
            r#"tool_result.contents == "hello""#,
            &tool("read_file"),
            None,
            None,
        ));
        assert!(!evaluate(
            r#"tool_result contains "sk-""#,
            &file("/etc/passwd"),
            None,
            None
        ));
        assert!(!evaluate(
            r#"tool_result.api_key starts_with "sk-""#,
            &network("https://example.com", "GET"),
            None,
            None,
        ));
        assert!(!evaluate(r#"tool_result contains "x""#, &process("ls"), None, None));
    }
}
