//! Static workflow verification — validate structure before execution.

use std::collections::{HashMap, HashSet, VecDeque};

use crate::types::*;

/// A single verification finding.
#[derive(Debug, Clone)]
pub struct WorkflowIssue {
    pub severity: String, // "error", "warning"
    pub stage_id: Option<String>,
    pub message: String,
}

/// Result of static workflow verification.
#[derive(Debug)]
pub struct WorkflowVerifyResult {
    pub valid: bool,
    pub issues: Vec<WorkflowIssue>,
    pub reachable_stages: Vec<String>,
    pub unreachable_stages: Vec<String>,
    pub has_cycles: bool,
}

/// Statically verify a workflow definition for structural correctness.
pub fn verify_workflow(workflow: &Workflow) -> WorkflowVerifyResult {
    let mut issues = Vec::new();
    let stage_ids: HashSet<&str> = workflow.stages.iter().map(|s| s.id.as_str()).collect();

    // 1. Start stage exists
    if !stage_ids.contains(workflow.start.as_str()) {
        issues.push(WorkflowIssue {
            severity: "error".into(),
            stage_id: None,
            message: format!("start stage '{}' does not exist", workflow.start),
        });
    }

    // 2. Edge references valid stage IDs
    for edge in &workflow.edges {
        if !stage_ids.contains(edge.from.as_str()) {
            issues.push(WorkflowIssue {
                severity: "error".into(),
                stage_id: None,
                message: format!("edge from '{}' references unknown stage", edge.from),
            });
        }
        if !stage_ids.contains(edge.to.as_str()) {
            issues.push(WorkflowIssue {
                severity: "error".into(),
                stage_id: None,
                message: format!("edge to '{}' references unknown stage", edge.to),
            });
        }
    }

    // 3. Compensation StageRef references valid, runnable stage IDs
    for stage in &workflow.stages {
        if let Some(CompensationHandler::StageRef { stage_id }) = &stage.compensation {
            match workflow.stage(stage_id) {
                None => issues.push(WorkflowIssue {
                    severity: "error".into(),
                    stage_id: Some(stage.id.clone()),
                    message: format!(
                        "compensation for stage '{}' references unknown stage '{}'",
                        stage.id, stage_id
                    ),
                }),
                Some(s) if matches!(s.step, StageStep::Approval(_)) => issues.push(WorkflowIssue {
                    severity: "error".into(),
                    stage_id: Some(stage.id.clone()),
                    message: format!(
                        "compensation for stage '{}' references approval gate '{}', which cannot be run as a compensation",
                        stage.id, stage_id
                    ),
                }),
                Some(_) => {}
            }
        }
    }

    // 3b. Approval gates must declare a non-empty output_key, otherwise the
    //     human's response on resume would be silently dropped.
    for stage in &workflow.stages {
        if let StageStep::Approval(ap) = &stage.step {
            if ap.output_key.trim().is_empty() {
                issues.push(WorkflowIssue {
                    severity: "error".into(),
                    stage_id: Some(stage.id.clone()),
                    message: format!("approval stage '{}' has an empty output_key", stage.id),
                });
            }
            if ap.output_key == "goal" {
                issues.push(WorkflowIssue {
                    severity: "error".into(),
                    stage_id: Some(stage.id.clone()),
                    message: format!(
                        "approval stage '{}' uses reserved output_key 'goal' (the drift anchor)",
                        stage.id
                    ),
                });
            }
        }
    }

    // 3c. Dedup steps need a non-empty items_from / into / store, otherwise the
    //     step can't read candidates, can't hand off the survivors, or can't
    //     name its persistent seen-set.
    for stage in &workflow.stages {
        if let StageStep::Dedup(ds) = &stage.step {
            for (field, val) in [
                ("items_from", &ds.items_from),
                ("into", &ds.into),
                ("store", &ds.store),
            ] {
                if val.trim().is_empty() {
                    issues.push(WorkflowIssue {
                        severity: "error".into(),
                        stage_id: Some(stage.id.clone()),
                        message: format!("dedup stage '{}' requires a non-empty {field}", stage.id),
                    });
                }
            }
            // `ttl_secs: 0` reads like "no TTL" but does the opposite — it evicts
            // every prior-run entry before the check, silently defeating
            // cross-run dedup. The "dedup forever" sentinel is an omitted/null
            // ttl_secs, not 0. Reject the trap.
            if ds.ttl_secs == Some(0) {
                issues.push(WorkflowIssue {
                    severity: "error".into(),
                    stage_id: Some(stage.id.clone()),
                    message: format!(
                        "dedup stage '{}' has ttl_secs: 0, which evicts everything each run (no cross-run dedup); omit ttl_secs to dedup forever, or use a positive window",
                        stage.id
                    ),
                });
            }
        }
    }

    // 3d. Deliver steps need at least one sink (an empty fan-out silently
    //     delivers nothing), and a deliver stage is documented as terminal —
    //     an outgoing edge is suspicious but not fatal.
    for stage in &workflow.stages {
        if let StageStep::Deliver(ds) = &stage.step {
            if ds.sinks.is_empty() {
                issues.push(WorkflowIssue {
                    severity: "error".into(),
                    stage_id: Some(stage.id.clone()),
                    message: format!("deliver stage '{}' requires at least one sink", stage.id),
                });
            }
            if workflow.edges.iter().any(|e| e.from == stage.id) {
                issues.push(WorkflowIssue {
                    severity: "warning".into(),
                    stage_id: Some(stage.id.clone()),
                    message: format!(
                        "deliver stage '{}' has outgoing edges; deliver is intended as a terminal fan-out",
                        stage.id
                    ),
                });
            }
        }
    }

    // 4. Reachability via BFS from start
    let adj: HashMap<&str, Vec<&str>> = {
        let mut m: HashMap<&str, Vec<&str>> = HashMap::new();
        for edge in &workflow.edges {
            m.entry(edge.from.as_str())
                .or_default()
                .push(edge.to.as_str());
        }
        m
    };

    let mut visited: HashSet<&str> = HashSet::new();
    let mut queue: VecDeque<&str> = VecDeque::new();
    if stage_ids.contains(workflow.start.as_str()) {
        queue.push_back(workflow.start.as_str());
        visited.insert(workflow.start.as_str());
    }
    while let Some(node) = queue.pop_front() {
        if let Some(neighbors) = adj.get(node) {
            for &next in neighbors {
                if visited.insert(next) {
                    queue.push_back(next);
                }
            }
        }
    }

    let reachable_stages: Vec<String> = visited.iter().map(|s| s.to_string()).collect();
    let unreachable_stages: Vec<String> = stage_ids
        .iter()
        .filter(|s| !visited.contains(**s))
        .map(|s| s.to_string())
        .collect();

    for id in &unreachable_stages {
        issues.push(WorkflowIssue {
            severity: "warning".into(),
            stage_id: Some(id.clone()),
            message: format!("stage '{}' is unreachable from start", id),
        });
    }

    // 5. Cycle detection via DFS
    let has_cycles = detect_cycles(&adj, workflow.start.as_str());
    if has_cycles {
        issues.push(WorkflowIssue {
            severity: "warning".into(),
            stage_id: None,
            message: "workflow contains cycles (ensure max_iterations is set)".into(),
        });
    }

    // 6. Recurse into sub-workflows
    for stage in &workflow.stages {
        if let StageStep::SubWorkflow(ref sw) = stage.step {
            let sub_result = verify_workflow(&sw.workflow);
            for issue in sub_result.issues {
                issues.push(WorkflowIssue {
                    severity: issue.severity,
                    stage_id: Some(format!(
                        "{}.{}",
                        stage.id,
                        issue.stage_id.unwrap_or_default()
                    )),
                    message: format!("[sub-workflow {}] {}", stage.id, issue.message),
                });
            }
        }
    }

    // 7. Proposal verification via car-verify (top-level proposals and each
    //    deliver sink — a sink is the same proposal shape).
    for stage in &workflow.stages {
        match &stage.step {
            StageStep::Proposal(ps) => {
                verify_proposal(&stage.id, "proposal", &ps.proposal, &mut issues);
            }
            StageStep::Deliver(ds) => {
                for (i, sink) in ds.sinks.iter().enumerate() {
                    verify_proposal(
                        &stage.id,
                        &format!("deliver sink {i} ({})", sink.id),
                        sink,
                        &mut issues,
                    );
                }
            }
            _ => {}
        }
    }

    // 8. LoopUntil / ForEach: validate the construct and recurse into its body
    //    (which sections 6 and 7 don't reach, since a body is not a top stage).
    for stage in &workflow.stages {
        validate_dynamic_step(&stage.id, &stage.step, &mut issues);
    }

    // 9. Bound nesting depth. Manifests cross the FFI/persistence boundary, so a
    //    pathologically deep nest of loop/foreach/sub-workflow bodies could drive
    //    unbounded recursion at execute time. Fail closed before then. (serde's
    //    own parse recursion limit also bounds JSON-built manifests, but this is
    //    explicit and covers in-process callers.)
    for stage in &workflow.stages {
        if exceeds_nesting(&stage.step, MAX_STEP_NESTING_DEPTH) {
            issues.push(WorkflowIssue {
                severity: "error".into(),
                stage_id: Some(stage.id.clone()),
                message: format!(
                    "stage '{}' nests loop/foreach/sub-workflow bodies deeper than the limit of {}",
                    stage.id, MAX_STEP_NESTING_DEPTH
                ),
            });
        }
    }

    let valid = !issues.iter().any(|i| i.severity == "error");

    WorkflowVerifyResult {
        valid,
        issues,
        reachable_stages,
        unreachable_stages,
        has_cycles,
    }
}

/// Run `car_verify` on a proposal and push its errors as workflow issues under
/// `[label]`. Shared by the top-level proposal pass and loop/foreach bodies.
fn verify_proposal(
    stage_id: &str,
    label: &str,
    proposal: &car_ir::ActionProposal,
    issues: &mut Vec<WorkflowIssue>,
) {
    let vr = car_verify::verify(proposal, None, None, 100);
    for issue in &vr.issues {
        if issue.severity == "error" {
            issues.push(WorkflowIssue {
                severity: "error".into(),
                stage_id: Some(stage_id.to_string()),
                message: format!("[{label}] {}", issue.message),
            });
        }
    }
}

/// Maximum nesting depth of loop/foreach/sub-workflow bodies. Chosen well below
/// serde_json's default parse recursion limit so a manifest that deserializes
/// can still be rejected here with a clear error.
pub(crate) const MAX_STEP_NESTING_DEPTH: usize = 32;

/// True if `step` nests loop/foreach/sub-workflow bodies deeper than `remaining`
/// levels. Recurses at most `remaining` deep, so it cannot itself overflow.
pub(crate) fn exceeds_nesting(step: &StageStep, remaining: usize) -> bool {
    if remaining == 0 {
        return true;
    }
    match step {
        StageStep::LoopUntil(ls) => exceeds_nesting(&ls.body, remaining - 1),
        StageStep::ForEach(fe) => exceeds_nesting(&fe.body, remaining - 1),
        StageStep::SubWorkflow(sw) => sw
            .workflow
            .stages
            .iter()
            .any(|s| exceeds_nesting(&s.step, remaining - 1)),
        _ => false,
    }
}

/// Validate `LoopUntil`/`ForEach` constructs and their inner bodies. A no-op for
/// other step kinds (handled by the top-level passes).
fn validate_dynamic_step(stage_id: &str, step: &StageStep, issues: &mut Vec<WorkflowIssue>) {
    match step {
        StageStep::LoopUntil(ls) => {
            if ls.max_iterations < 1 {
                issues.push(WorkflowIssue {
                    severity: "error".into(),
                    stage_id: Some(stage_id.to_string()),
                    message: format!("loop_until stage '{stage_id}' requires max_iterations >= 1"),
                });
            }
            validate_body(stage_id, "loop_until", &ls.body, issues);
        }
        StageStep::ForEach(fe) => {
            if fe.items_from.trim().is_empty() {
                issues.push(WorkflowIssue {
                    severity: "error".into(),
                    stage_id: Some(stage_id.to_string()),
                    message: format!("for_each stage '{stage_id}' requires a non-empty items_from"),
                });
            }
            validate_body(stage_id, "for_each", &fe.body, issues);
        }
        _ => {}
    }
}

/// Validate the body of a `LoopUntil`/`ForEach`: reject approval gates, verify a
/// proposal body, recurse into a sub-workflow body, and recurse for nested
/// loop/foreach bodies.
fn validate_body(
    stage_id: &str,
    parent_kind: &str,
    body: &StageStep,
    issues: &mut Vec<WorkflowIssue>,
) {
    match body {
        StageStep::Approval(_) => issues.push(WorkflowIssue {
            severity: "error".into(),
            stage_id: Some(stage_id.to_string()),
            message: format!(
                "{parent_kind} stage '{stage_id}' body cannot be an approval gate (no pause/resume inside a loop or fan-out)"
            ),
        }),
        StageStep::Proposal(ps) => {
            verify_proposal(
                stage_id,
                &format!("{parent_kind} body proposal"),
                &ps.proposal,
                issues,
            );
        }
        StageStep::SubWorkflow(sw) => {
            let sub = verify_workflow(&sw.workflow);
            for issue in sub.issues {
                issues.push(WorkflowIssue {
                    severity: issue.severity,
                    stage_id: Some(format!("{stage_id}.{}", issue.stage_id.unwrap_or_default())),
                    message: format!("[{parent_kind} body sub-workflow] {}", issue.message),
                });
            }
        }
        // Nested loop/foreach: recurse to validate the inner construct + its body.
        StageStep::LoopUntil(_) | StageStep::ForEach(_) => {
            validate_dynamic_step(stage_id, body, issues)
        }
        // A deliver body is unusual (deliver is intended terminal) but harmless;
        // still enforce the deliver rules here — the top-level pass only walks
        // stages, not bodies, so without this an empty-sinks deliver body
        // would pass verification and silently no-op at runtime.
        StageStep::Deliver(ds) => {
            if ds.sinks.is_empty() {
                issues.push(WorkflowIssue {
                    severity: "error".into(),
                    stage_id: Some(stage_id.to_string()),
                    message: format!(
                        "{parent_kind} stage '{stage_id}' has a deliver body with no sinks (requires at least one sink)"
                    ),
                });
            }
            for (i, sink) in ds.sinks.iter().enumerate() {
                verify_proposal(
                    stage_id,
                    &format!("{parent_kind} body deliver sink {i}"),
                    sink,
                    issues,
                );
            }
        }
        // Leaf steps: nothing further to validate here (a Dedup body is unusual
        // but harmless; the top-level pass already checked its fields).
        StageStep::Pattern(_) | StageStep::Dedup(_) => {}
    }
}

/// Static semantic checks beyond graph structure: surface edge-condition keys
/// and proposal state-dependencies that no stage produces. These are *advisory*
/// (a key may be produced at runtime via a proposal's `state_changes` that isn't
/// declared in `expected_effects`), so callers should treat them as warnings,
/// not hard errors. The builder feeds them back as repair hints.
///
/// "Produced" keys: `user_input`/`user_query`; `stage.<id>.{succeeded,answer,
/// error}` for every stage; an approval stage's `output_key` and
/// `output_key.<field>`; and every proposal action's `expected_effects` keys.
pub fn semantic_issues(workflow: &Workflow) -> Vec<String> {
    let mut produced: HashSet<String> = HashSet::new();
    produced.insert("user_input".into());
    produced.insert("user_query".into());
    for stage in &workflow.stages {
        produced.insert(format!("stage.{}.succeeded", stage.id));
        produced.insert(format!("stage.{}.answer", stage.id));
        produced.insert(format!("stage.{}.error", stage.id));
        match &stage.step {
            StageStep::Approval(ap) => {
                produced.insert(ap.output_key.clone());
                for f in &ap.fields {
                    produced.insert(format!("{}.{}", ap.output_key, f.name));
                }
            }
            StageStep::Proposal(ps) => {
                for action in &ps.proposal.actions {
                    for k in action.expected_effects.keys() {
                        produced.insert(k.clone());
                    }
                }
            }
            StageStep::LoopUntil(ls) => {
                // A loop exposes its iteration count, and its body's proposal
                // effects flow into workflow state across iterations.
                produced.insert(format!("stage.{}.iteration", stage.id));
                if let StageStep::Proposal(ps) = ls.body.as_ref() {
                    for action in &ps.proposal.actions {
                        for k in action.expected_effects.keys() {
                            produced.insert(k.clone());
                        }
                    }
                }
            }
            StageStep::ForEach(_) => {
                // Per-item keys (`foreach.<id>.<i>.{item,answer,state.*}`) are
                // indexed at runtime and can't be enumerated statically; only the
                // count is. Edges branching on a specific item index will thus
                // get an advisory (non-blocking) "no stage produces" hint.
                produced.insert(format!("foreach.{}.count", stage.id));
            }
            StageStep::Dedup(ds) => {
                produced.insert(ds.into.clone());
                produced.insert(format!("stage.{}.unseen_count", stage.id));
                produced.insert(format!("stage.{}.total_count", stage.id));
            }
            StageStep::Deliver(_) => {
                produced.insert(format!("stage.{}.delivered_count", stage.id));
                produced.insert(format!("stage.{}.failed_count", stage.id));
            }
            StageStep::Pattern(_) | StageStep::SubWorkflow(_) => {}
        }
    }

    let mut issues = Vec::new();
    for edge in &workflow.edges {
        for cond in &edge.conditions {
            if !produced.contains(&cond.key) {
                issues.push(format!(
                    "edge {}->{} branches on state key '{}', which no stage produces (the branch may never be taken)",
                    edge.from, edge.to, cond.key
                ));
            }
        }
    }
    for stage in &workflow.stages {
        if let StageStep::Proposal(ps) = &stage.step {
            for action in &ps.proposal.actions {
                for dep in &action.state_dependencies {
                    if !produced.contains(dep) {
                        issues.push(format!(
                            "stage '{}' depends on state key '{}', which no stage produces",
                            stage.id, dep
                        ));
                    }
                }
            }
        }
    }
    issues
}

/// DFS-based cycle detection.
fn detect_cycles(adj: &HashMap<&str, Vec<&str>>, start: &str) -> bool {
    let mut visited = HashSet::new();
    let mut stack = HashSet::new();

    fn dfs<'a>(
        node: &'a str,
        adj: &HashMap<&'a str, Vec<&'a str>>,
        visited: &mut HashSet<&'a str>,
        stack: &mut HashSet<&'a str>,
    ) -> bool {
        visited.insert(node);
        stack.insert(node);

        if let Some(neighbors) = adj.get(node) {
            for &next in neighbors {
                if stack.contains(next) {
                    return true; // back edge = cycle
                }
                if !visited.contains(next) && dfs(next, adj, visited, stack) {
                    return true;
                }
            }
        }

        stack.remove(node);
        false
    }

    dfs(start, adj, &mut visited, &mut stack)
}

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

    fn make_stage(id: &str) -> Stage {
        Stage {
            id: id.into(),
            name: id.into(),
            step: StageStep::Proposal(ProposalStep {
                proposal: ActionProposal {
                    id: format!("p-{}", id),
                    source: "test".into(),
                    actions: vec![],
                    timestamp: chrono::Utc::now(),
                    context: std::collections::HashMap::new(),
                },
            }),
            compensation: None,
            timeout_ms: None,
            metadata: std::collections::HashMap::new(),
        }
    }

    #[test]
    fn valid_linear_workflow() {
        let wf = Workflow {
            id: "test".into(),
            name: "Test".into(),
            start: "a".into(),
            goal: None,
            stages: vec![make_stage("a"), make_stage("b"), make_stage("c")],
            edges: vec![
                Edge {
                    from: "a".into(),
                    to: "b".into(),
                    conditions: vec![],
                    label: String::new(),
                },
                Edge {
                    from: "b".into(),
                    to: "c".into(),
                    conditions: vec![],
                    label: String::new(),
                },
            ],
            max_iterations: 100,
            metadata: std::collections::HashMap::new(),
        };
        let result = verify_workflow(&wf);
        assert!(result.valid);
        assert!(!result.has_cycles);
        assert_eq!(result.reachable_stages.len(), 3);
        assert!(result.unreachable_stages.is_empty());
    }

    #[test]
    fn missing_start_stage() {
        let wf = Workflow {
            id: "test".into(),
            name: "Test".into(),
            start: "nonexistent".into(),
            goal: None,
            stages: vec![make_stage("a")],
            edges: vec![],
            max_iterations: 100,
            metadata: std::collections::HashMap::new(),
        };
        let result = verify_workflow(&wf);
        assert!(!result.valid);
        assert!(result
            .issues
            .iter()
            .any(|i| i.message.contains("nonexistent")));
    }

    #[test]
    fn unreachable_stage() {
        let wf = Workflow {
            id: "test".into(),
            name: "Test".into(),
            start: "a".into(),
            goal: None,
            stages: vec![make_stage("a"), make_stage("b"), make_stage("orphan")],
            edges: vec![Edge {
                from: "a".into(),
                to: "b".into(),
                conditions: vec![],
                label: String::new(),
            }],
            max_iterations: 100,
            metadata: std::collections::HashMap::new(),
        };
        let result = verify_workflow(&wf);
        assert!(result.valid); // unreachable is a warning, not error
        assert_eq!(result.unreachable_stages.len(), 1);
        assert!(result.unreachable_stages.contains(&"orphan".to_string()));
    }

    #[test]
    fn cycle_detected() {
        let wf = Workflow {
            id: "test".into(),
            name: "Test".into(),
            start: "a".into(),
            goal: None,
            stages: vec![make_stage("a"), make_stage("b")],
            edges: vec![
                Edge {
                    from: "a".into(),
                    to: "b".into(),
                    conditions: vec![],
                    label: String::new(),
                },
                Edge {
                    from: "b".into(),
                    to: "a".into(),
                    conditions: vec![],
                    label: String::new(),
                },
            ],
            max_iterations: 100,
            metadata: std::collections::HashMap::new(),
        };
        let result = verify_workflow(&wf);
        assert!(result.valid); // cycles are warnings
        assert!(result.has_cycles);
    }

    #[test]
    fn invalid_edge_reference() {
        let wf = Workflow {
            id: "test".into(),
            name: "Test".into(),
            start: "a".into(),
            goal: None,
            stages: vec![make_stage("a")],
            edges: vec![Edge {
                from: "a".into(),
                to: "ghost".into(),
                conditions: vec![],
                label: String::new(),
            }],
            max_iterations: 100,
            metadata: std::collections::HashMap::new(),
        };
        let result = verify_workflow(&wf);
        assert!(!result.valid);
        assert!(result.issues.iter().any(|i| i.message.contains("ghost")));
    }

    fn approval_stage(id: &str, output_key: &str) -> Stage {
        Stage {
            id: id.into(),
            name: id.into(),
            step: StageStep::Approval(crate::types::ApprovalStep {
                prompt: "approve?".into(),
                fields: vec![],
                output_key: output_key.into(),
            }),
            compensation: None,
            timeout_ms: None,
            metadata: std::collections::HashMap::new(),
        }
    }

    #[test]
    fn semantic_issues_flag_unknown_edge_key_and_dependency() {
        let wf = Workflow {
            id: "t".into(),
            name: "T".into(),
            start: "gate".into(),
            goal: None,
            stages: vec![approval_stage("gate", "approval"), make_stage("done")],
            edges: vec![
                // Branches on a field the gate never declares → flagged.
                Edge {
                    from: "gate".into(),
                    to: "done".into(),
                    conditions: vec![car_ir::Precondition {
                        key: "approval.decision".into(),
                        operator: "eq".into(),
                        value: serde_json::Value::String("approve".into()),
                        description: String::new(),
                    }],
                    label: String::new(),
                },
            ],
            max_iterations: 100,
            metadata: std::collections::HashMap::new(),
        };
        let issues = semantic_issues(&wf);
        assert!(issues.iter().any(|i| i.contains("approval.decision")));

        // A key the gate DOES produce is not flagged.
        let wf_ok = Workflow {
            edges: vec![Edge {
                from: "gate".into(),
                to: "done".into(),
                conditions: vec![car_ir::Precondition {
                    key: "stage.gate.succeeded".into(),
                    operator: "eq".into(),
                    value: serde_json::Value::Bool(true),
                    description: String::new(),
                }],
                label: String::new(),
            }],
            ..wf
        };
        assert!(semantic_issues(&wf_ok).is_empty());
    }

    #[test]
    fn approval_empty_output_key_is_error() {
        let wf = Workflow {
            id: "test".into(),
            name: "Test".into(),
            start: "gate".into(),
            goal: None,
            stages: vec![approval_stage("gate", "")],
            edges: vec![],
            max_iterations: 100,
            metadata: std::collections::HashMap::new(),
        };
        let result = verify_workflow(&wf);
        assert!(!result.valid);
        assert!(result
            .issues
            .iter()
            .any(|i| i.message.contains("empty output_key")));
    }

    #[test]
    fn approval_as_compensation_is_error() {
        let mut work = make_stage("work");
        work.compensation = Some(CompensationHandler::StageRef {
            stage_id: "gate".into(),
        });
        let wf = Workflow {
            id: "test".into(),
            name: "Test".into(),
            start: "work".into(),
            goal: None,
            stages: vec![work, approval_stage("gate", "approval")],
            edges: vec![],
            max_iterations: 100,
            metadata: std::collections::HashMap::new(),
        };
        let result = verify_workflow(&wf);
        assert!(!result.valid);
        assert!(result
            .issues
            .iter()
            .any(|i| i.message.contains("cannot be run as a compensation")));
    }

    #[test]
    fn invalid_compensation_ref() {
        let mut stage = make_stage("a");
        stage.compensation = Some(CompensationHandler::StageRef {
            stage_id: "nonexistent".into(),
        });
        let wf = Workflow {
            id: "test".into(),
            name: "Test".into(),
            start: "a".into(),
            goal: None,
            stages: vec![stage],
            edges: vec![],
            max_iterations: 100,
            metadata: std::collections::HashMap::new(),
        };
        let result = verify_workflow(&wf);
        assert!(!result.valid);
    }

    fn single_step_wf(step: StageStep) -> Workflow {
        Workflow {
            id: "test".into(),
            name: "Test".into(),
            start: "s".into(),
            goal: None,
            stages: vec![Stage {
                id: "s".into(),
                name: "s".into(),
                step,
                compensation: None,
                timeout_ms: None,
                metadata: std::collections::HashMap::new(),
            }],
            edges: vec![],
            max_iterations: 100,
            metadata: std::collections::HashMap::new(),
        }
    }

    #[test]
    fn loop_until_zero_iterations_is_error() {
        let wf = single_step_wf(StageStep::LoopUntil(LoopUntilStep {
            body: Box::new(make_stage("b").step),
            until: vec![],
            max_iterations: 0,
        }));
        let result = verify_workflow(&wf);
        assert!(!result.valid);
        assert!(result
            .issues
            .iter()
            .any(|i| i.message.contains("max_iterations >= 1")));
    }

    #[test]
    fn for_each_empty_items_from_is_error() {
        let wf = single_step_wf(StageStep::ForEach(ForEachStep {
            items_from: "  ".into(),
            body: Box::new(make_stage("b").step),
            max_concurrent: 0,
        }));
        let result = verify_workflow(&wf);
        assert!(!result.valid);
        assert!(result
            .issues
            .iter()
            .any(|i| i.message.contains("non-empty items_from")));
    }

    #[test]
    fn dedup_zero_ttl_is_error() {
        let wf = single_step_wf(StageStep::Dedup(DedupStep {
            items_from: "items".into(),
            into: "unseen".into(),
            store: "s".into(),
            hash_fields: vec![],
            ttl_secs: Some(0),
        }));
        let result = verify_workflow(&wf);
        assert!(!result.valid);
        assert!(result
            .issues
            .iter()
            .any(|i| i.message.contains("ttl_secs: 0")));

        // Omitted ttl (dedup forever) and a positive window are both fine.
        for ttl in [None, Some(3600)] {
            let ok = single_step_wf(StageStep::Dedup(DedupStep {
                items_from: "items".into(),
                into: "unseen".into(),
                store: "s".into(),
                hash_fields: vec![],
                ttl_secs: ttl,
            }));
            assert!(verify_workflow(&ok).valid);
        }
    }

    #[test]
    fn deliver_requires_at_least_one_sink() {
        let wf = single_step_wf(StageStep::Deliver(crate::types::DeliverStep {
            sinks: vec![],
            payload_key: None,
        }));
        let result = verify_workflow(&wf);
        assert!(!result.valid);
        assert!(result
            .issues
            .iter()
            .any(|i| i.message.contains("at least one sink")));

        // One (empty-action) sink verifies clean.
        let ok = single_step_wf(StageStep::Deliver(crate::types::DeliverStep {
            sinks: vec![car_ir::ActionProposal {
                id: "s".into(),
                source: "test".into(),
                actions: vec![],
                timestamp: chrono::Utc::now(),
                context: std::collections::HashMap::new(),
            }],
            payload_key: Some("digest".into()),
        }));
        assert!(verify_workflow(&ok).valid);
    }

    #[test]
    fn deliver_body_with_no_sinks_is_error() {
        // The >=1-sink rule must also reach a deliver nested as a loop/foreach
        // body — the top-level pass only walks stages, so without the body
        // check this would verify clean and silently no-op at runtime.
        let wf = single_step_wf(StageStep::ForEach(ForEachStep {
            items_from: "items".into(),
            body: Box::new(StageStep::Deliver(crate::types::DeliverStep {
                sinks: vec![],
                payload_key: None,
            })),
            max_concurrent: 0,
        }));
        let result = verify_workflow(&wf);
        assert!(!result.valid);
        assert!(result
            .issues
            .iter()
            .any(|i| i.severity == "error" && i.message.contains("deliver body with no sinks")));

        // Same rule via a loop_until body.
        let wf = single_step_wf(StageStep::LoopUntil(LoopUntilStep {
            body: Box::new(StageStep::Deliver(crate::types::DeliverStep {
                sinks: vec![],
                payload_key: None,
            })),
            until: vec![],
            max_iterations: 1,
        }));
        assert!(!verify_workflow(&wf).valid);
    }

    #[test]
    fn excessive_nesting_is_error() {
        // Build a loop nested deeper than the cap.
        let mut step = make_stage("leaf").step;
        for _ in 0..(MAX_STEP_NESTING_DEPTH + 2) {
            step = StageStep::LoopUntil(LoopUntilStep {
                body: Box::new(step),
                until: vec![],
                max_iterations: 1,
            });
        }
        let wf = single_step_wf(step);
        let result = verify_workflow(&wf);
        assert!(!result.valid);
        assert!(result.issues.iter().any(|i| i.message.contains("nests")));
    }

    #[test]
    fn nesting_within_limit_is_ok() {
        let mut step = make_stage("leaf").step;
        for _ in 0..4 {
            step = StageStep::LoopUntil(LoopUntilStep {
                body: Box::new(step),
                until: vec![],
                max_iterations: 1,
            });
        }
        let wf = single_step_wf(step);
        let result = verify_workflow(&wf);
        assert!(result.valid, "issues: {:?}", result.issues);
    }

    #[test]
    fn approval_inside_loop_body_is_error() {
        let wf = single_step_wf(StageStep::LoopUntil(LoopUntilStep {
            body: Box::new(StageStep::Approval(crate::types::ApprovalStep {
                prompt: "p".into(),
                fields: vec![],
                output_key: "k".into(),
            })),
            until: vec![],
            max_iterations: 3,
        }));
        let result = verify_workflow(&wf);
        assert!(!result.valid);
        assert!(result
            .issues
            .iter()
            .any(|i| i.message.contains("cannot be an approval gate")));
    }
}
