use std::collections::{BTreeMap, BTreeSet, HashMap};

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ParamDef {
    #[serde(rename = "type")]
    pub param_type: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub format: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub required: Option<bool>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub default: Option<Value>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RetryPolicy {
    pub max_attempts: u64,
    pub backoff: String,
    pub base_ms: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub factor: Option<f64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub jitter: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct HumanGate {
    pub stage: String,
    pub prompt: Value,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub approvers: Option<Vec<String>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub deadline_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub on_timeout: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LoopTerminate {
    pub node: String,
    pub via: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LoopOutputProjection {
    pub from: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct NodeBase {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub depends: Option<Vec<String>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub human_gate: Option<HumanGate>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retry_policy: Option<RetryPolicy>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timeout_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_output_bytes: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub output_schema: Option<Value>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub unsafe_allow_ungated: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SubagentNode {
    #[serde(flatten)]
    pub base: NodeBase,
    pub bot: String,
    pub prompt: Value,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub working_dir: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model_overrides: Option<Value>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tool_policy: Option<Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct HostExecutorNode {
    #[serde(flatten)]
    pub base: NodeBase,
    pub executor: String,
    pub input: Value,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LoopNode {
    #[serde(flatten)]
    pub base: NodeBase,
    pub max_iterations: u64,
    pub body: Vec<String>,
    pub terminate: LoopTerminate,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub output: Option<LoopOutputProjection>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DecisionNode {
    #[serde(flatten)]
    pub base: NodeBase,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum WorkflowNode {
    Subagent(SubagentNode),
    HostExecutor(HostExecutorNode),
    Loop(LoopNode),
    Decision(DecisionNode),
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct WorkflowDefaults {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retry_policy: Option<RetryPolicy>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timeout_ms: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_output_bytes: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub max_concurrency: Option<u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct WorkflowDefinition {
    pub workflow_id: String,
    pub version: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub params: Option<BTreeMap<String, ParamDef>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub defaults: Option<WorkflowDefaults>,
    pub nodes: BTreeMap<String, WorkflowNode>,
}

pub fn parse_workflow_definition(raw: &str) -> Result<WorkflowDefinition> {
    let def: WorkflowDefinition =
        serde_json::from_str(raw).context("failed to parse workflow json")?;
    validate_workflow_definition(&def)?;
    Ok(def)
}

/// Side-effect executors that MUST be gated (humanGate or unsafeAllowUngated).
const SIDE_EFFECT_EXECUTORS: &[&str] = &["feishu-send", "feishu-reply", "beam-schedule"];

fn is_side_effect_executor(executor: &str) -> bool {
    SIDE_EFFECT_EXECUTORS.contains(&executor)
}

pub fn validate_workflow_definition(def: &WorkflowDefinition) -> Result<()> {
    if def.workflow_id.trim().is_empty() {
        anyhow::bail!("workflowId 缺失");
    }
    if def.version == 0 {
        anyhow::bail!("version must be positive");
    }
    if def.nodes.is_empty() {
        anyhow::bail!("Workflow must declare at least one node");
    }
    for node_id in def.nodes.keys() {
        // Node id must match ^[A-Za-z0-9_.-]+$ (non-empty)
        if node_id.is_empty() {
            anyhow::bail!(
                "nodeId '' rejected: must match ^[A-Za-z0-9_.-]+$"
            );
        }
        if !node_id
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.' || ch == '-')
        {
            anyhow::bail!(
                "nodeId '{}' rejected: must match ^[A-Za-z0-9_.-]+$",
                node_id
            );
        }
        // Preserve existing path-traversal rejection
        if node_id == "." || node_id == ".." || node_id.contains("..") {
            anyhow::bail!(
                "nodeId '{}' rejected: path-traversal style ids are not allowed",
                node_id
            );
        }
    }
    for (node_id, node) in &def.nodes {
        match node {
            WorkflowNode::HostExecutor(host) => {
                // Side-effect executors must be gated
                if is_side_effect_executor(&host.executor)
                    && host.base.human_gate.is_none()
                    && !host.base.unsafe_allow_ungated.unwrap_or(false)
                {
                    anyhow::bail!(
                        "nodeId '{}': side-effect executor '{}' must have a humanGate or set unsafeAllowUngated: true",
                        node_id,
                        host.executor
                    );
                }
            }
            _ => {}
        }
    }
    for (node_id, node) in &def.nodes {
        for dep in node_depends(node) {
            if !def.nodes.contains_key(dep) {
                anyhow::bail!("Node '{}' depends on unknown node '{}'", node_id, dep);
            }
            if dep == node_id {
                anyhow::bail!("Node '{}' depends on itself", node_id);
            }
        }
    }
    detect_cycles(def)?;

    // Loop definition validation (Task 8.3)
    validate_loop_definitions(def)?;

    // Check for at least one scheduler-visible root node
    // (exclude body nodes and Decision nodes — they're dispatched inside their loop context)
    let body_owner = build_body_owner_map(def);
    let has_root = def.nodes.iter().any(|(node_id, node)| {
        if body_owner.contains_key(node_id) {
            return false;
        }
        if matches!(node, WorkflowNode::Decision(_)) {
            return false;
        }
        node_depends(node).is_empty()
    });
    if !has_root {
        anyhow::bail!(
            "Workflow has no scheduler-visible root node (every non-loop-body, non-decision node has dependencies)"
        );
    }
    Ok(())
}

fn node_depends(node: &WorkflowNode) -> &[String] {
    match node {
        WorkflowNode::Subagent(n) => n.base.depends.as_deref().unwrap_or(&[]),
        WorkflowNode::HostExecutor(n) => n.base.depends.as_deref().unwrap_or(&[]),
        WorkflowNode::Loop(n) => n.base.depends.as_deref().unwrap_or(&[]),
        WorkflowNode::Decision(n) => n.base.depends.as_deref().unwrap_or(&[]),
    }
}

fn detect_cycles(def: &WorkflowDefinition) -> Result<()> {
    #[derive(Clone, Copy, PartialEq, Eq)]
    enum Mark {
        Temp,
        Perm,
    }
    fn visit(
        node_id: &str,
        def: &WorkflowDefinition,
        marks: &mut BTreeMap<String, Mark>,
        stack: &mut BTreeSet<String>,
    ) -> Result<()> {
        if matches!(marks.get(node_id), Some(Mark::Perm)) {
            return Ok(());
        }
        if matches!(marks.get(node_id), Some(Mark::Temp)) {
            anyhow::bail!("Workflow graph contains a cycle at '{}'", node_id);
        }
        marks.insert(node_id.to_string(), Mark::Temp);
        stack.insert(node_id.to_string());
        for dep in node_depends(def.nodes.get(node_id).expect("node must exist")) {
            visit(dep, def, marks, stack)?;
        }
        stack.remove(node_id);
        marks.insert(node_id.to_string(), Mark::Perm);
        Ok(())
    }

    let mut marks = BTreeMap::new();
    let mut stack = BTreeSet::new();
    for node_id in def.nodes.keys() {
        visit(node_id, def, &mut marks, &mut stack)?;
    }
    Ok(())
}

/// Build a map from each body node id → its owning loop node id.
fn build_body_owner_map(def: &WorkflowDefinition) -> HashMap<String, String> {
    let mut owner = HashMap::new();
    for (loop_id, node) in &def.nodes {
        if let WorkflowNode::Loop(loop_node) = node {
            for body_id in &loop_node.body {
                owner.insert(body_id.clone(), loop_id.clone());
            }
        }
    }
    owner
}

/// Validate loop definitions per Task 8.3 rules:
/// 1. body nodes must exist
/// 2. body nodes cannot be loops (no nested loops)
/// 3. terminate.node must exist, be in body, be a human-gated Decision, and
///    terminate.via must be "humanGate"; all Decisions must have a loop owner
/// 4. each loop body can have at most one Decision (the terminate.node)
/// 5. body external deps must appear in loop.depends
/// 6. external nodes cannot depend on loop body nodes
/// 7. output.from must identify an output-producing body node
/// 8. sink loops must declare output.from
fn validate_loop_definitions(def: &WorkflowDefinition) -> Result<()> {
    let body_owner = build_body_owner_map(def);

    // Track which Decision node is owned by which loop (via terminate.node)
    let mut decision_loop_owner: HashMap<String, String> = HashMap::new();

    for (loop_id, node) in &def.nodes {
        let loop_node = match node {
            WorkflowNode::Loop(ln) => ln,
            _ => continue,
        };

        // Rule 1: body nodes must exist in workflow nodes
        for body_id in &loop_node.body {
            if !def.nodes.contains_key(body_id) {
                anyhow::bail!(
                    "loop '{}' body node '{}' not found in workflow nodes",
                    loop_id,
                    body_id
                );
            }
        }

        // Rule 2: body nodes cannot be Loop (no nested loops)
        for body_id in &loop_node.body {
            if matches!(def.nodes.get(body_id), Some(WorkflowNode::Loop(_))) {
                anyhow::bail!(
                    "loop '{}' body node '{}' cannot be a Loop node (nested loops are not supported)",
                    loop_id,
                    body_id
                );
            }
        }

        // Rule 3a: terminate.node must exist
        let term_node_id = &loop_node.terminate.node;
        if !def.nodes.contains_key(term_node_id) {
            anyhow::bail!(
                "loop '{}' terminate.node '{}' not found in workflow nodes",
                loop_id,
                term_node_id
            );
        }

        // Rule 3a: terminate.node must be in the loop body
        if !loop_node.body.contains(term_node_id) {
            anyhow::bail!(
                "loop '{}' terminate.node '{}' must be in the loop body",
                loop_id,
                term_node_id
            );
        }

        // Rule 3a: terminate.node must be a Decision node. Gate details are
        // checked after structural validation to preserve useful diagnostics.
        match def.nodes.get(term_node_id) {
            Some(WorkflowNode::Decision(_)) => {
                // Each Decision can belong to at most one loop
                if let Some(existing_owner) =
                    decision_loop_owner.insert(term_node_id.clone(), loop_id.clone())
                {
                    anyhow::bail!(
                        "Decision node '{}' is used as terminate.node by multiple loops: '{}' and '{}'",
                        term_node_id,
                        existing_owner,
                        loop_id
                    );
                }
            }
            _ => {
                anyhow::bail!(
                    "loop '{}' terminate.node '{}' must be a Decision node, got {:?}",
                    loop_id,
                    term_node_id,
                    std::mem::discriminant(def.nodes.get(term_node_id).unwrap())
                );
            }
        }

        // Rule 4: each loop body can have at most one Decision (the terminate.node)
        for body_id in &loop_node.body {
            if body_id != term_node_id
                && matches!(def.nodes.get(body_id), Some(WorkflowNode::Decision(_)))
            {
                anyhow::bail!(
                    "loop '{}' has Decision node '{}' in body that is not the terminate.node; \
                     each loop body can have at most one Decision node (which must be the terminate.node)",
                    loop_id,
                    body_id
                );
            }
        }

        // Rule 5: body external deps must be declared in loop.depends
        let loop_depends: BTreeSet<&str> = loop_node
            .base
            .depends
            .as_ref()
            .map(|v| v.iter().map(String::as_str).collect())
            .unwrap_or_default();
        for body_id in &loop_node.body {
            if let Some(body_node) = def.nodes.get(body_id) {
                for dep in node_depends(body_node) {
                    if !loop_node.body.contains(dep) {
                        // dep is external to the loop body
                        if !loop_depends.contains(dep.as_str()) {
                            anyhow::bail!(
                                "loop '{}' body node '{}' depends on external node '{}'; \
                                 all external dependencies of body nodes must be declared in the loop's depends",
                                loop_id,
                                body_id,
                                dep
                            );
                        }
                    }
                }
            }
        }
    }

    // Rule 3b: all Decision nodes must be owned by some loop (no standalone Decision)
    for (node_id, node) in &def.nodes {
        if matches!(node, WorkflowNode::Decision(_)) {
            if !decision_loop_owner.contains_key(node_id) {
                anyhow::bail!(
                    "Decision node '{}' is standalone; Decision nodes must be used as a loop's terminate.node and reside in that loop's body",
                    node_id
                );
            }
        }
    }

    // Rule 6: non-body nodes (including loop nodes themselves) cannot depend on
    // loop body nodes.  External nodes that need a loop's result must depend on
    // the loop node itself, not on individual body nodes.
    for (node_id, node) in &def.nodes {
        if body_owner.contains_key(node_id) {
            continue; // skip body nodes themselves
        }
        if matches!(node, WorkflowNode::Decision(_)) {
            continue; // Decision nodes not owned by a loop are already rejected by rule 3b;
                      // owned Decision nodes are body nodes (skip above)
        }
        for dep in node_depends(node) {
            if body_owner.contains_key(dep) {
                let owner = body_owner.get(dep).unwrap();
                anyhow::bail!(
                    "node '{}' depends on loop body node '{}'; \
                     nodes must depend on the loop node '{}' instead of its body node",
                    node_id,
                    dep,
                    owner
                );
            }
        }
    }

    // Rule 7: every declared output must come from an output-producing body node.
    for (loop_id, node) in &def.nodes {
        let WorkflowNode::Loop(loop_node) = node else {
            continue;
        };
        let Some(output) = &loop_node.output else {
            continue;
        };
        if !loop_node.body.contains(&output.from) {
            anyhow::bail!(
                "loop '{}' output.from '{}' must identify a node in the loop body",
                loop_id,
                output.from
            );
        }
        if !matches!(
            def.nodes.get(&output.from),
            Some(WorkflowNode::Subagent(_) | WorkflowNode::HostExecutor(_))
        ) {
            anyhow::bail!(
                "loop '{}' output.from '{}' must identify an output-producing Subagent or HostExecutor body node",
                loop_id,
                output.from
            );
        }
    }

    // Rule 8: sink loops must declare output.from
    // A loop is a "sink" if no non-body, non-decision node depends on it.
    let sinks = find_non_body_sinks(def, &body_owner);
    for sink_id in &sinks {
        if let Some(WorkflowNode::Loop(loop_node)) = def.nodes.get(sink_id) {
            if loop_node.output.is_none() {
                anyhow::bail!(
                    "sink loop '{}' must declare output.from (the loop is not depended on by any external node)",
                    sink_id
                );
            }
        }
    }

    // Loop termination is only implemented through a human gate.
    for (loop_id, node) in &def.nodes {
        let WorkflowNode::Loop(loop_node) = node else {
            continue;
        };
        if loop_node.terminate.via != "humanGate" {
            anyhow::bail!(
                "loop '{}' terminate.via must be 'humanGate', got '{}'",
                loop_id,
                loop_node.terminate.via
            );
        }
        let decision = match def.nodes.get(&loop_node.terminate.node) {
            Some(WorkflowNode::Decision(decision)) => decision,
            _ => continue,
        };
        if decision.base.human_gate.is_none() {
            anyhow::bail!(
                "loop '{}' terminate.node '{}' must declare humanGate",
                loop_id,
                loop_node.terminate.node
            );
        }
    }

    Ok(())
}

/// Find sink nodes excluding body nodes and Decision nodes.
/// A node is a sink if no non-body, non-decision node depends on it.
fn find_non_body_sinks(
    def: &WorkflowDefinition,
    body_owner: &HashMap<String, String>,
) -> Vec<String> {
    let mut referenced: BTreeSet<String> = BTreeSet::new();
    for (node_id, node) in &def.nodes {
        if body_owner.contains_key(node_id) {
            continue;
        }
        if matches!(node, WorkflowNode::Decision(_)) {
            continue;
        }
        for dep in node_depends(node) {
            referenced.insert(dep.clone());
        }
    }
    def.nodes
        .iter()
        .filter_map(|(node_id, node)| {
            if body_owner.contains_key(node_id) {
                return None;
            }
            if matches!(node, WorkflowNode::Decision(_)) {
                return None;
            }
            (!referenced.contains(node_id)).then_some(node_id.clone())
        })
        .collect()
}

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

    #[test]
    fn parse_workflow_definition_accepts_basic_graph() {
        let def = parse_workflow_definition(
            r#"{
                "workflowId":"flow-a",
                "version":1,
                "nodes":{
                    "a":{"type":"subagent","bot":"bot-a","prompt":"hi"},
                    "b":{"type":"hostExecutor","executor":"feishu-send","input":1,"depends":["a"],"humanGate":{"stage":"before","prompt":"approve?"}}
                }
            }"#,
        )
        .expect("definition");
        assert_eq!(def.workflow_id, "flow-a");
        assert_eq!(def.nodes.len(), 2);
    }

    // -- Task 1.1: node id validation --

    #[test]
    fn reject_node_id_with_slash() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"node/a":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
        )
        .unwrap_err();
        assert!(err.to_string().contains("nodeId"), "got: {err}");
    }

    #[test]
    fn reject_node_id_with_space() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"node a":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
        )
        .unwrap_err();
        assert!(err.to_string().contains("nodeId"), "got: {err}");
    }

    #[test]
    fn reject_node_id_dotdot() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"..":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
        )
        .unwrap_err();
        assert!(err.to_string().contains("nodeId"), "got: {err}");
    }

    #[test]
    fn reject_node_id_containing_dotdot() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"a..b":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
        )
        .unwrap_err();
        assert!(err.to_string().contains("nodeId"), "got: {err}");
    }

    #[test]
    fn reject_empty_node_id() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
        )
        .unwrap_err();
        assert!(err.to_string().contains("nodeId"), "got: {err}");
    }

    #[test]
    fn accept_node_id_with_dash() {
        let def = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"node-a":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
        )
        .expect("dash ok");
        assert!(def.nodes.contains_key("node-a"));
    }

    #[test]
    fn accept_node_id_with_underscore() {
        let def = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"node_a":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
        )
        .expect("underscore ok");
        assert!(def.nodes.contains_key("node_a"));
    }

    #[test]
    fn accept_node_id_with_dot() {
        let def = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"node.a":{"type":"subagent","bot":"b","prompt":"p"}}}"#,
        )
        .expect("dot ok");
        assert!(def.nodes.contains_key("node.a"));
    }

    // -- Task 1.2: side-effect executor gate validation --

    #[test]
    fn reject_ungated_feishu_send() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":1}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("humanGate") || err.to_string().contains("side-effect"),
            "got: {err}"
        );
    }

    #[test]
    fn reject_ungated_feishu_reply() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-reply","input":1}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("humanGate") || err.to_string().contains("side-effect"),
            "got: {err}"
        );
    }

    #[test]
    fn reject_ungated_beam_schedule() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"beam-schedule","input":1}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("humanGate") || err.to_string().contains("side-effect"),
            "got: {err}"
        );
    }

    #[test]
    fn accept_gated_feishu_send() {
        let def = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":1,"humanGate":{"stage":"before","prompt":"ok?"}}}}"#,
        )
        .expect("gated feishu-send ok");
        assert!(def.nodes.contains_key("a"));
    }

    #[test]
    fn accept_unsafe_allow_ungated_feishu_send() {
        let def = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":1,"unsafeAllowUngated":true}}}"#,
        )
        .expect("unsafeAllowUngated ok");
        assert!(def.nodes.contains_key("a"));
    }

    #[test]
    fn accept_ungated_non_side_effect_executor() {
        let def = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"custom-tool","input":1}}}"#,
        )
        .expect("non-side-effect ok");
        assert!(def.nodes.contains_key("a"));
    }

    // -- Task 8.2: loop nodes are now accepted (validation deferred to Task 8.3) --

    #[test]
    fn accept_loop_node_with_minimal_body() {
        // Sink loops need both an output-producing body node and a gated decision.
        let def = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"work":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["work"]},"l":{"type":"loop","maxIterations":3,"body":["work","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"work"}}}}"#,
        )
        .expect("loop accepted");
        assert!(def.nodes.contains_key("l"));
    }

    #[test]
    fn reject_standalone_decision_node() {
        // Task 8.3: standalone Decision nodes (not used as a loop terminate.node) are rejected
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("standalone"),
            "expected 'standalone' in error, got: {err}"
        );
    }

    #[test]
    fn ordinary_dag_workflow_accepted() {
        let def = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"subagent","bot":"b","prompt":"p"},"c":{"type":"subagent","bot":"b","prompt":"q","depends":["a"]}}}"#,
        )
        .expect("ordinary DAG ok");
        assert_eq!(def.nodes.len(), 2);
    }

    // -- Task 8.2: code-review-loop workflow now parses successfully --
    #[test]
    fn accept_code_review_loop_workflow_json() {
        let raw = include_str!("../../../workflows/code-review-loop.workflow.json");
        let def = parse_workflow_definition(raw).expect("code-review-loop parsed");
        assert!(def.nodes.contains_key("review-loop"));
        assert!(def.nodes.contains_key("implement"));
        assert!(def.nodes.contains_key("review"));
        assert!(def.nodes.contains_key("reviewDecision"));
    }

    // -- Task 9.2: subagent-approval-feishu-send example parses --
    #[test]
    fn accept_subagent_approval_feishu_send_workflow_json() {
        let raw =
            include_str!("../../../workflows/subagent-approval-feishu-send.workflow.json");
        let def = parse_workflow_definition(raw).expect("subagent-approval-feishu-send parsed");
        assert_eq!(def.workflow_id, "subagent-approval-feishu-send");
        assert_eq!(def.nodes.len(), 2);
        assert!(def.nodes.contains_key("draft"));
        assert!(def.nodes.contains_key("send"));
        // send depends on draft
        let send_node = def.nodes.get("send").unwrap();
        match send_node {
            // send is a gated feishu-send: humanGate present, unsafeAllowUngated absent
            WorkflowNode::HostExecutor(n) => {
                assert_eq!(n.executor, "feishu-send");
                assert_eq!(n.base.depends.as_deref(), Some(vec!["draft".to_string()].as_slice()));
                assert!(n.base.human_gate.is_some(), "send must have humanGate");
                assert!(!n.base.unsafe_allow_ungated.unwrap_or(false), "send must NOT use unsafeAllowUngated");
            }
            _ => panic!("expected hostExecutor"),
        }
    }

    // -- Task 8.3: loop definition validation --

    // Rule 1: body nodes must exist
    #[test]
    fn reject_loop_with_missing_body_node() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"l":{"type":"loop","maxIterations":3,"body":["missing","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"d"}}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("body node") && err.to_string().contains("missing"),
            "expected body node 'missing' error, got: {err}"
        );
    }

    // Rule 2: body node cannot be a loop (no nested loops)
    #[test]
    fn reject_loop_with_nested_loop_body() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"inner":{"type":"loop","maxIterations":2,"body":["d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"d"}},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"d"}}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("nested loop") || err.to_string().contains("cannot be a Loop"),
            "expected nested loop error, got: {err}"
        );
    }

    // Rule 3a: terminate.node must exist
    #[test]
    fn reject_loop_with_missing_terminate_node() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"l":{"type":"loop","maxIterations":3,"body":["d"],"terminate":{"node":"nonexistent","via":"humanGate"},"output":{"from":"d"}}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("terminate.node") && err.to_string().contains("nonexistent"),
            "expected terminate.node not found error, got: {err}"
        );
    }

    // Rule 3a: terminate.node must be in loop body
    #[test]
    fn reject_loop_with_terminate_node_not_in_body() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"x":{"type":"subagent","bot":"b","prompt":"p"},"l":{"type":"loop","maxIterations":3,"body":["d"],"terminate":{"node":"x","via":"humanGate"}}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("terminate.node") && err.to_string().contains("body"),
            "expected terminate.node must be in body error, got: {err}"
        );
    }

    // Rule 3a: terminate.node must be a Decision node
    #[test]
    fn reject_loop_with_non_decision_terminate_node() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"subagent","bot":"b","prompt":"p"},"l":{"type":"loop","maxIterations":3,"body":["a"],"terminate":{"node":"a","via":"humanGate"}}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("Decision node"),
            "expected Decision node error, got: {err}"
        );
    }

    #[test]
    fn reject_loop_with_ungated_decision_terminate_node() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"work":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision","depends":["work"]},"l":{"type":"loop","maxIterations":3,"body":["work","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"work"}}}}"#,
        )
        .unwrap_err();
        assert!(err.to_string().contains("humanGate"), "got: {err}");
    }

    #[test]
    fn reject_loop_with_non_human_gate_terminate_via() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"work":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["work"]},"l":{"type":"loop","maxIterations":3,"body":["work","d"],"terminate":{"node":"d","via":"automatic"},"output":{"from":"work"}}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("terminate.via") && err.to_string().contains("humanGate"),
            "got: {err}"
        );
    }

    // Rule 3b: Decision node cannot be standalone
    #[test]
    fn reject_decision_not_used_by_any_loop() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"a":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision"}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("standalone"),
            "expected standalone Decision error, got: {err}"
        );
    }

    // Rule 3b: Decision node must be in body of the loop that uses it
    #[test]
    fn reject_decision_outside_loop_body() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"l":{"type":"loop","maxIterations":3,"body":[],"terminate":{"node":"d","via":"humanGate"}}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("body"),
            "expected body error for terminate.node not in body, got: {err}"
        );
    }

    // Rule 3b: same Decision cannot be used by multiple loops
    #[test]
    fn reject_decision_used_by_multiple_loops() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"l1":{"type":"loop","maxIterations":3,"body":["d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"d"}},"l2":{"type":"loop","maxIterations":3,"body":["d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"d"}}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("multiple loops"),
            "expected 'multiple loops' error, got: {err}"
        );
    }

    // Rule 4: each loop body can have at most one Decision (the terminate.node)
    #[test]
    fn reject_loop_with_extra_decision_in_body() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d1":{"type":"decision"},"d2":{"type":"decision"},"l":{"type":"loop","maxIterations":3,"body":["d1","d2"],"terminate":{"node":"d1","via":"humanGate"},"output":{"from":"d1"}}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("at most one Decision"),
            "expected 'at most one Decision' error, got: {err}"
        );
    }

    // Rule 5: body external deps must appear in loop.depends
    #[test]
    fn reject_body_node_with_undeclared_external_dependency() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"ext":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision"},"inner":{"type":"subagent","bot":"b","prompt":"p","depends":["ext"]},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"inner"}}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("external") && err.to_string().contains("depends"),
            "expected external dep must be declared in loop.depends error, got: {err}"
        );
    }

    #[test]
    fn accept_body_node_with_external_dependency_declared_in_loop_depends() {
        let def = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"ext":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner"]},"inner":{"type":"subagent","bot":"b","prompt":"p","depends":["ext"]},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"depends":["ext"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"inner"}}}}"#,
        )
        .expect("loop with declared external dep accepted");
        assert!(def.nodes.contains_key("l"));
    }

    // Rule 6: external nodes cannot depend on loop body nodes
    #[test]
    fn reject_external_node_depending_on_loop_body_node() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"inner":{"type":"subagent","bot":"b","prompt":"p"},"ext":{"type":"subagent","bot":"b","prompt":"p","depends":["inner"]},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"inner"}}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("depends on loop body node"),
            "expected depends on loop body node error, got: {err}"
        );
    }

    #[test]
    fn accept_external_node_depending_on_loop_node_instead_of_body_node() {
        let def = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner"]},"inner":{"type":"subagent","bot":"b","prompt":"p"},"ext":{"type":"subagent","bot":"b","prompt":"p","depends":["l"]},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"inner"}}}}"#,
        )
        .expect("external node depends on loop node accepted");
        assert!(def.nodes.contains_key("l"));
    }

    // Rule 6 (cont): loop node itself must not depend on body nodes
    // (either its own body or another loop's body)
    #[test]
    fn reject_loop_depends_on_own_body_node() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"inner":{"type":"subagent","bot":"b","prompt":"p"},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"depends":["inner"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"inner"}}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("body node"),
            "expected 'body node' error for loop depending on own body node, got: {err}"
        );
    }

    #[test]
    fn reject_loop_depends_on_another_loop_body_node() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d1":{"type":"decision"},"a":{"type":"subagent","bot":"b","prompt":"p"},"l1":{"type":"loop","maxIterations":3,"body":["a","d1"],"terminate":{"node":"d1","via":"humanGate"},"output":{"from":"a"}},"d2":{"type":"decision"},"l2":{"type":"loop","maxIterations":3,"body":["d2"],"depends":["a"],"terminate":{"node":"d2","via":"humanGate"},"output":{"from":"d2"}}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("body node") && err.to_string().contains("'l2'"),
            "expected 'body node' error for l2 depending on another loop's body node, got: {err}"
        );
    }

    // Also verify that loop depends on another loop (not body node) is still accepted
    #[test]
    fn accept_loop_depends_on_another_loop_node() {
        let def = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d1":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["a"]},"a":{"type":"subagent","bot":"b","prompt":"p"},"l1":{"type":"loop","maxIterations":3,"body":["a","d1"],"terminate":{"node":"d1","via":"humanGate"},"output":{"from":"a"}},"b":{"type":"subagent","bot":"b","prompt":"q"},"d2":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["b"]},"l2":{"type":"loop","maxIterations":3,"body":["b","d2"],"depends":["l1"],"terminate":{"node":"d2","via":"humanGate"},"output":{"from":"b"}}}}"#,
        )
        .expect("loop depends on another loop node accepted");
        assert!(def.nodes.contains_key("l1"));
        assert!(def.nodes.contains_key("l2"));
    }

    // Rule 8: sink loop must declare output.from
    #[test]
    fn reject_sink_loop_without_output_from() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision"},"inner":{"type":"subagent","bot":"b","prompt":"p"},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"}}}}"#,
        )
        .unwrap_err();
        assert!(
            err.to_string().contains("output.from"),
            "expected 'output.from' error for sink loop, got: {err}"
        );
    }

    #[test]
    fn accept_sink_loop_with_output_from() {
        let def = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner"]},"inner":{"type":"subagent","bot":"b","prompt":"p"},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"inner"}}}}"#,
        )
        .expect("sink loop with output.from accepted");
        assert!(def.nodes.contains_key("l"));
    }

    #[test]
    fn reject_sink_loop_with_unknown_output_from() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"inner":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner"]},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"missing"}}}}"#,
        )
        .unwrap_err();
        assert!(err.to_string().contains("loop body"), "got: {err}");
    }

    #[test]
    fn reject_sink_loop_with_external_output_from() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"external":{"type":"subagent","bot":"b","prompt":"p"},"inner":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner"]},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"external"}}}}"#,
        )
        .unwrap_err();
        assert!(err.to_string().contains("loop body"), "got: {err}");
    }

    #[test]
    fn reject_sink_loop_with_decision_output_from() {
        let err = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"inner":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner"]},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"d"}}}}"#,
        )
        .unwrap_err();
        assert!(err.to_string().contains("output-producing"), "got: {err}");
    }

    #[test]
    fn accept_non_sink_loop_without_output_from() {
        // Loop is not a sink because external node 'ext' depends on it
        let def = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner"]},"inner":{"type":"subagent","bot":"b","prompt":"p"},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"terminate":{"node":"d","via":"humanGate"}},"ext":{"type":"subagent","bot":"b","prompt":"p","depends":["l"]}}}"#,
        )
        .expect("non-sink loop without output.from accepted");
        assert!(def.nodes.contains_key("l"));
    }

    // Edge case: loop with depends on external node but no body external deps (valid)
    #[test]
    fn accept_loop_with_explicit_depends_and_no_body_external_deps() {
        let def = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"ext":{"type":"subagent","bot":"b","prompt":"p"},"d":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner"]},"inner":{"type":"subagent","bot":"b","prompt":"p"},"l":{"type":"loop","maxIterations":3,"body":["inner","d"],"depends":["ext"],"terminate":{"node":"d","via":"humanGate"},"output":{"from":"inner"}}}}"#,
        )
        .expect("loop with explicit depends accepted");
        assert!(def.nodes.contains_key("l"));
    }

    // Regression: multiple loops in same workflow
    #[test]
    fn accept_two_independent_loops() {
        let def = parse_workflow_definition(
            r#"{"workflowId":"f","version":1,"nodes":{"d1":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner1"]},"inner1":{"type":"subagent","bot":"b","prompt":"p"},"l1":{"type":"loop","maxIterations":3,"body":["inner1","d1"],"terminate":{"node":"d1","via":"humanGate"},"output":{"from":"inner1"}},"d2":{"type":"decision","humanGate":{"stage":"approve","prompt":"continue?"},"depends":["inner2"]},"inner2":{"type":"subagent","bot":"b","prompt":"p"},"l2":{"type":"loop","maxIterations":3,"body":["inner2","d2"],"depends":["l1"],"terminate":{"node":"d2","via":"humanGate"},"output":{"from":"inner2"}}}}"#,
        )
        .expect("two independent loops accepted");
        assert!(def.nodes.contains_key("l1"));
        assert!(def.nodes.contains_key("l2"));
    }
}
