//! Formula DAG — typed workspace taskserv execution graph.
//!
//! A `Formula` is the Rust-side representation of a `schemas/lib/formula.ncl` export.
//! The Orchestrator uses it to build a real `DependencyGraph` instead of the linear
//! positional chain that `batch.rs` previously created.
//!
//! Pipeline:
//!   `nickel export formula.ncl --format json` → `Formula::from_json` → `WorkflowDefinition`

use std::collections::HashMap;

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

use crate::workflow::{WorkflowConfig, WorkflowDefinition, WorkflowTaskDefinition};

/// How a node responds to its own failure.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum OnError {
    #[default]
    Stop,
    Continue,
    Retry,
}

/// Dependency edge kind inside a FormulaNode's `depends_on` list.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum DepKind {
    Always,
    #[default]
    OnSuccess,
    OnFailure,
}

/// Intra-node dependency declaration: `node_id` of the dependency and when to trigger.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormulaDep {
    pub node_id: String,
    #[serde(default)]
    pub kind: DepKind,
}

/// Capability kind as declared in `ExtensionDependency.kind`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum CapabilityKind {
    #[default]
    Required,
    Optional,
    ConflictsWith,
}

/// A capability provided by an extension (maps to `ExtensionCapability` in Nickel).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExtensionCapability {
    pub id: String,
    pub version: String,
    pub interface: String,
}

/// A capability required by an extension (maps to `ExtensionDependency` in Nickel).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExtensionDependency {
    pub capability: String,
    #[serde(default)]
    pub kind: CapabilityKind,
    pub min_version: Option<String>,
}

/// A taskserv definition embedded in a FormulaNode (maps to `TaskServDef` in Nickel).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskServDef {
    pub name: String,
    #[serde(default = "default_install_mode")]
    pub install_mode: String,
    #[serde(default = "default_profile")]
    pub profile: String,
    #[serde(default)]
    pub target_save_path: String,
    /// Capabilities this taskserv satisfies — from extension metadata provides[].
    #[serde(default)]
    pub provides: Vec<ExtensionCapability>,
    /// Capabilities this taskserv requires — from extension metadata requires[].
    #[serde(default)]
    pub requires: Vec<ExtensionDependency>,
    /// Extension names this taskserv conflicts with — from extension metadata conflicts_with[].
    #[serde(default)]
    pub conflicts_with: Vec<String>,
}

fn default_install_mode() -> String {
    "library".to_string()
}

fn default_profile() -> String {
    "default".to_string()
}

fn default_deploy_mode() -> DeployMode {
    DeployMode::Cluster
}

/// Deployment mode for a component node — determines where and how the component is installed.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeployMode {
    Taskserv,
    #[default]
    Cluster,
    Container,
}

impl std::fmt::Display for DeployMode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            DeployMode::Taskserv => write!(f, "taskserv"),
            DeployMode::Cluster => write!(f, "cluster"),
            DeployMode::Container => write!(f, "container"),
        }
    }
}

/// A component definition embedded in a FormulaNode (maps to `ComponentDef` in Nickel).
///
/// Present when a node uses the unified L3+ component model instead of the legacy `taskserv` field.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentDef {
    pub name: String,
    #[serde(default = "default_deploy_mode")]
    pub mode: DeployMode,
    pub target: Option<String>,
    pub namespace: Option<String>,
    #[serde(default = "default_install_mode")]
    pub install_mode: String,
    #[serde(default = "default_profile")]
    pub profile: String,
    #[serde(default)]
    pub target_save_path: String,
}

/// A node in the Formula DAG — wraps either a `TaskServDef` (L2, legacy) or a `ComponentDef`
/// (L3+ unified model) with execution metadata. Exactly one of the two fields must be present.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormulaNode {
    pub id: String,
    /// L2 legacy nodes — present when `component` is absent.
    pub taskserv: Option<TaskServDef>,
    /// L3+ unified model nodes — present when `taskserv` is absent.
    pub component: Option<ComponentDef>,
    #[serde(default)]
    pub depends_on: Vec<FormulaDep>,
    #[serde(default)]
    pub parallel: bool,
    #[serde(default)]
    pub on_error: OnError,
    #[serde(default)]
    pub max_retries: u8,
}

/// An explicit edge between two FormulaNodes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormulaEdge {
    pub from: String,
    pub to: String,
    #[serde(default)]
    pub kind: DepKind,
}

/// The Formula DAG — exported from `schemas/lib/formula.ncl` via `nickel export --format json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Formula {
    pub id: String,
    pub description: String,
    pub provider: String,
    pub server: String,
    pub nodes: Vec<FormulaNode>,
    #[serde(default)]
    pub edges: Vec<FormulaEdge>,
    #[serde(default = "default_max_parallel")]
    pub max_parallel: usize,
}

fn default_max_parallel() -> usize {
    4
}

/// Configuration for converting a `Formula` into a `WorkflowDefinition`.
pub struct FormulaWorkflowConfig<'a> {
    pub operation_cmd: &'a str,
    pub infra_name: &'a str,
    pub settings_path: &'a str,
    pub provisioning_path: &'a str,
    pub environment: HashMap<String, String>,
    pub check_mode: bool,
    pub wait: bool,
}

impl Formula {
    /// Deserialize from the JSON output of `nickel export --format json formula.ncl`.
    pub fn from_json(json: &str) -> Result<Self> {
        serde_json::from_str(json).context("Failed to deserialize Formula from nickel export JSON")
    }

    /// Build a `WorkflowDefinition` from this formula.
    ///
    /// Each `FormulaNode` becomes a `WorkflowTaskDefinition`. Dependencies are wired
    /// from `depends_on` (node-level) and `edges` (formula-level). The resulting
    /// `WorkflowDefinition` feeds directly into `BatchWorkflowEngine::execute_workflow`,
    /// which runs the `DependencyGraph` topological sort and respects `max_parallel_tasks`.
    pub fn into_workflow(self, cfg: FormulaWorkflowConfig<'_>) -> Result<WorkflowDefinition> {
        let FormulaWorkflowConfig {
            operation_cmd,
            infra_name,
            settings_path,
            provisioning_path,
            environment,
            check_mode,
            wait,
        } = cfg;
        // Build node_id → task_name mapping (task names are unique within the workflow).
        let task_names: HashMap<String, String> = self
            .nodes
            .iter()
            .map(|n| {
                let logical_name = Self::node_logical_name(n);
                (n.id.clone(), Self::task_name(operation_cmd, &n.id, logical_name))
            })
            .collect();

        let mut tasks = Vec::with_capacity(self.nodes.len());

        for node in &self.nodes {
            let task_name = task_names[&node.id].clone();

            // Collect dependencies from node.depends_on + formula-level edges that point to this node.
            let mut dep_task_names: Vec<String> = node
                .depends_on
                .iter()
                .filter_map(|dep| task_names.get(&dep.node_id).cloned())
                .collect();

            // Formula-level edges supplement node-level depends_on.
            for edge in &self.edges {
                if edge.to == node.id {
                    if let Some(src_task) = task_names.get(&edge.from) {
                        if !dep_task_names.contains(src_task) {
                            dep_task_names.push(src_task.clone());
                        }
                    }
                }
            }

            let max_retries = if node.on_error == OnError::Retry {
                Some(node.max_retries.max(1))
            } else {
                Some(0)
            };

            let mut meta = HashMap::new();
            meta.insert("formula_id".to_string(), self.id.clone());
            meta.insert("node_id".to_string(), node.id.clone());
            meta.insert("provider".to_string(), self.provider.clone());
            meta.insert("server".to_string(), self.server.clone());
            meta.insert("parallel".to_string(), node.parallel.to_string());
            meta.insert("on_error".to_string(), format!("{:?}", node.on_error));

            let (command, args) = match (&node.taskserv, &node.component) {
                (Some(ts), None) => {
                    meta.insert("taskserv".to_string(), ts.name.clone());
                    let mut a = vec![
                        ts.name.clone(),
                        "--infra".to_string(),
                        infra_name.to_string(),
                        "--settings".to_string(),
                        settings_path.to_string(),
                    ];
                    if ts.profile != "default" {
                        a.push("--profile".to_string());
                        a.push(ts.profile.clone());
                    }
                    if !ts.target_save_path.is_empty() {
                        a.push("--target-path".to_string());
                        a.push(ts.target_save_path.clone());
                    }
                    if check_mode {
                        a.push("--check".to_string());
                    }
                    if wait {
                        a.push("--wait".to_string());
                    }
                    (format!("{provisioning_path} {operation_cmd}"), a)
                }
                (None, Some(comp)) => {
                    let mode_str = comp.mode.to_string();
                    meta.insert("component".to_string(), comp.name.clone());
                    meta.insert("mode".to_string(), mode_str.clone());
                    let mut a = vec![
                        comp.name.clone(),
                        "--mode".to_string(),
                        mode_str,
                        "--infra".to_string(),
                        infra_name.to_string(),
                        "--settings".to_string(),
                        settings_path.to_string(),
                    ];
                    if comp.profile != "default" {
                        a.push("--profile".to_string());
                        a.push(comp.profile.clone());
                    }
                    if !comp.target_save_path.is_empty() {
                        a.push("--target-path".to_string());
                        a.push(comp.target_save_path.clone());
                    }
                    if let Some(ref ns) = comp.namespace {
                        a.push("--namespace".to_string());
                        a.push(ns.clone());
                    }
                    if check_mode {
                        a.push("--check".to_string());
                    }
                    if wait {
                        a.push("--wait".to_string());
                    }
                    (format!("{provisioning_path} component install"), a)
                }
                (Some(_), Some(_)) => {
                    anyhow::bail!(
                        "FormulaNode '{}' has both `taskserv` and `component` — only one is allowed",
                        node.id
                    );
                }
                (None, None) => {
                    anyhow::bail!(
                        "FormulaNode '{}' has neither `taskserv` nor `component` — one is required",
                        node.id
                    );
                }
            };

            tasks.push(WorkflowTaskDefinition {
                name: task_name,
                command,
                args,
                dependencies: dep_task_names,
                provider: Some(self.provider.clone()),
                timeout_seconds: None,
                max_retries,
                environment: Some(environment.clone()),
                metadata: Some(meta),
            });
        }

        Ok(WorkflowDefinition {
            name: format!("formula_{}", self.id),
            description: Some(self.description.clone()),
            tasks,
            config: Some(WorkflowConfig {
                max_parallel_tasks: self.max_parallel,
                ..WorkflowConfig::default()
            }),
        })
    }

    /// Return the logical name used in task naming: taskserv name for L2 nodes, component name for L3+.
    fn node_logical_name(node: &FormulaNode) -> &str {
        if let Some(ref ts) = node.taskserv {
            return ts.name.as_str();
        }
        if let Some(ref comp) = node.component {
            return comp.name.as_str();
        }
        node.id.as_str()
    }

    fn task_name(operation_cmd: &str, node_id: &str, logical_name: &str) -> String {
        format!(
            "{}_{}_{}",
            operation_cmd.replace(' ', "_"),
            node_id,
            logical_name
        )
    }
}

// ---------------------------------------------------------------------------
// Workspace Composition — inter-formula DAG
// ---------------------------------------------------------------------------

/// Condition required from a depended formula before the dependent formula starts.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum CompositionCondition {
    Completed,
    Healthy,
    Running,
}

/// Dependency edge between two formulas in a WorkspaceComposition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InterFormulaDep {
    pub formula_id: String,
    pub condition: CompositionCondition,
}

/// Health gate checked between two formula groups.
/// Inserted as a synthetic `WorkflowTaskDefinition` between depended terminals and dependent roots.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompositionHealthGate {
    pub check_cmd: String,
    pub expect: String,
    pub timeout_ms: u64,
    pub retries: u8,
}

/// One formula entry in the composition graph.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormulaCompositionEntry {
    pub formula_id: String,
    #[serde(default)]
    pub depends_on: Vec<InterFormulaDep>,
    #[serde(default)]
    pub parallel: bool,
    pub health_gate: Option<CompositionHealthGate>,
}

/// The composition graph field inside WorkspaceComposition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompositionGraph {
    pub formulas: Vec<FormulaCompositionEntry>,
}

/// Workspace-level inter-formula DAG. Deserialized from `dag.ncl` via `nickel export --format json`.
///
/// Composes multiple per-server `Formula` DAGs into a single `WorkflowDefinition`,
/// injecting health gate tasks and inter-formula dependency edges.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkspaceComposition {
    pub workspace: String,
    pub infra: String,
    pub composition: CompositionGraph,
}

impl WorkspaceComposition {
    /// Deserialize from the JSON output of `nickel export --format json dag.ncl`.
    pub fn from_json(json: &str) -> Result<Self> {
        serde_json::from_str(json)
            .context("Failed to deserialize WorkspaceComposition from dag.ncl export")
    }

    /// Build a single `WorkflowDefinition` from all formulas in the composition.
    ///
    /// Per formula: calls `Formula::into_workflow`, namespaces all task names as
    /// `"{formula_id}::{task_name}"`, computes root tasks (no dependencies) and
    /// terminal tasks (no dependents). Then for each inter-formula dependency:
    /// if a health gate is declared, injects a synthetic task between them;
    /// otherwise wires terminal → root directly.
    pub fn into_workflow(
        self,
        formulas: Vec<Formula>,
        cfg: FormulaWorkflowConfig<'_>,
    ) -> Result<WorkflowDefinition> {
        use std::collections::{HashMap, HashSet};

        let formula_map: HashMap<String, Formula> = formulas
            .into_iter()
            .map(|f| (f.id.clone(), f))
            .collect();

        // Validate all formula_id references before doing any work.
        for entry in &self.composition.formulas {
            if !formula_map.contains_key(&entry.formula_id) {
                anyhow::bail!(
                    "dag.ncl references formula_id '{}' not found in servers.ncl export",
                    entry.formula_id
                );
            }
            for dep in &entry.depends_on {
                if !formula_map.contains_key(&dep.formula_id) {
                    anyhow::bail!(
                        "dag.ncl depends_on references unknown formula_id '{}'",
                        dep.formula_id
                    );
                }
            }
        }

        let mut all_tasks: Vec<WorkflowTaskDefinition> = Vec::new();
        let mut formula_roots: HashMap<String, Vec<String>> = HashMap::new();
        let mut formula_terminals: HashMap<String, Vec<String>> = HashMap::new();

        for entry in &self.composition.formulas {
            let formula = formula_map[&entry.formula_id].clone();
            let prefix = entry.formula_id.clone();

            let wf = formula.into_workflow(FormulaWorkflowConfig {
                operation_cmd: cfg.operation_cmd,
                infra_name: &entry.formula_id,
                settings_path: cfg.settings_path,
                provisioning_path: cfg.provisioning_path,
                environment: cfg.environment.clone(),
                check_mode: cfg.check_mode,
                wait: cfg.wait,
            })?;

            // Namespace all task names and their dependency references.
            let namespaced: Vec<WorkflowTaskDefinition> = wf.tasks.into_iter().map(|t| {
                WorkflowTaskDefinition {
                    name: format!("{}::{}", prefix, t.name),
                    dependencies: t.dependencies.into_iter()
                        .map(|d| format!("{}::{}", prefix, d))
                        .collect(),
                    ..t
                }
            }).collect();

            // Root tasks: no dependencies within this formula.
            let dep_targets: HashSet<String> = namespaced.iter()
                .flat_map(|t| t.dependencies.iter().cloned())
                .collect();

            let roots: Vec<String> = namespaced.iter()
                .filter(|t| t.dependencies.is_empty())
                .map(|t| t.name.clone())
                .collect();

            // Terminal tasks: no other task depends on them within this formula.
            let terminals: Vec<String> = namespaced.iter()
                .filter(|t| !dep_targets.contains(&t.name))
                .map(|t| t.name.clone())
                .collect();

            formula_roots.insert(entry.formula_id.clone(), roots);
            formula_terminals.insert(entry.formula_id.clone(), terminals);
            all_tasks.extend(namespaced);
        }

        // Wire inter-formula dependencies, injecting health gates as synthetic tasks.
        for entry in &self.composition.formulas {
            for dep in &entry.depends_on {
                let depended_terminals = formula_terminals
                    .get(&dep.formula_id)
                    .with_context(|| format!("terminal_tasks missing for '{}'", dep.formula_id))?
                    .clone();

                let dependent_roots = formula_roots
                    .get(&entry.formula_id)
                    .with_context(|| format!("root_tasks missing for '{}'", entry.formula_id))?
                    .clone();

                if let Some(gate) = &entry.health_gate {
                    let gate_name = format!(
                        "{}::health-gate::{}",
                        dep.formula_id, entry.formula_id
                    );
                    let gate_task = WorkflowTaskDefinition {
                        name: gate_name.clone(),
                        command: gate.check_cmd.clone(),
                        args: vec![],
                        dependencies: depended_terminals,
                        provider: None,
                        timeout_seconds: Some(gate.timeout_ms / 1000),
                        max_retries: Some(gate.retries),
                        environment: None,
                        metadata: Some(HashMap::from([
                            ("type".to_string(), "health-gate".to_string()),
                            ("expect".to_string(), gate.expect.clone()),
                            ("depended_formula".to_string(), dep.formula_id.clone()),
                            ("dependent_formula".to_string(), entry.formula_id.clone()),
                        ])),
                    };
                    for root in &dependent_roots {
                        if let Some(task) = all_tasks.iter_mut().find(|t| &t.name == root) {
                            task.dependencies.push(gate_name.clone());
                        }
                    }
                    all_tasks.push(gate_task);
                } else {
                    // No health gate: dependent roots wait directly on depended terminals.
                    for root in &dependent_roots {
                        if let Some(task) = all_tasks.iter_mut().find(|t| &t.name == root) {
                            task.dependencies.extend(depended_terminals.clone());
                        }
                    }
                }
            }
        }

        Ok(WorkflowDefinition {
            name: format!("{}::{}", self.workspace, self.infra),
            description: Some(format!(
                "Composed workflow: {} formulas for {}/{}",
                self.composition.formulas.len(),
                self.workspace,
                self.infra,
            )),
            tasks: all_tasks,
            config: None,
        })
    }
}

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

    const SAMPLE_FORMULA_JSON: &str = r#"{
        "id": "wuji-cp-0-formula",
        "description": "Control plane DAG",
        "provider": "hetzner",
        "server": "wuji-cp-0",
        "max_parallel": 3,
        "nodes": [
            {
                "id": "etcd",
                "taskserv": {"name": "etcd", "install_mode": "library", "profile": "default", "target_save_path": "/wuwei/wuji/etcd"},
                "depends_on": [],
                "parallel": false,
                "on_error": "Stop",
                "max_retries": 0
            },
            {
                "id": "k8s",
                "taskserv": {"name": "kubernetes", "install_mode": "library", "profile": "control-plane", "target_save_path": "/wuwei/wuji"},
                "depends_on": [{"node_id": "etcd", "kind": "OnSuccess"}],
                "parallel": true,
                "on_error": "Stop",
                "max_retries": 0
            },
            {
                "id": "containerd",
                "taskserv": {"name": "containerd", "install_mode": "library", "profile": "default", "target_save_path": ""},
                "depends_on": [{"node_id": "etcd", "kind": "OnSuccess"}],
                "parallel": true,
                "on_error": "Stop",
                "max_retries": 0
            },
            {
                "id": "cilium",
                "taskserv": {"name": "cilium", "install_mode": "library", "profile": "default", "target_save_path": ""},
                "depends_on": [
                    {"node_id": "k8s", "kind": "OnSuccess"},
                    {"node_id": "containerd", "kind": "OnSuccess"}
                ],
                "parallel": false,
                "on_error": "Stop",
                "max_retries": 0
            }
        ],
        "edges": []
    }"#;

    #[test]
    fn deserialize_formula_from_json() {
        let formula = Formula::from_json(SAMPLE_FORMULA_JSON).unwrap();
        assert_eq!(formula.id, "wuji-cp-0-formula");
        assert_eq!(formula.nodes.len(), 4);
        assert_eq!(formula.max_parallel, 3);
    }

    #[test]
    fn formula_into_workflow_builds_real_dag() {
        let formula = Formula::from_json(SAMPLE_FORMULA_JSON).unwrap();
        let workflow = formula
            .into_workflow(FormulaWorkflowConfig {
                operation_cmd: "taskserv create",
                infra_name: "librecloud",
                settings_path: "/settings.ncl",
                provisioning_path: "./provisioning",
                environment: std::collections::HashMap::new(),
                check_mode: false,
                wait: true,
            })
            .unwrap();

        assert_eq!(workflow.tasks.len(), 4);

        // etcd has no dependencies
        let etcd = workflow.tasks.iter().find(|t| t.name.contains("etcd")).unwrap();
        assert!(etcd.dependencies.is_empty(), "etcd must have no deps");

        // cilium depends on both k8s and containerd
        let cilium = workflow.tasks.iter().find(|t| t.name.contains("cilium")).unwrap();
        assert_eq!(cilium.dependencies.len(), 2, "cilium must depend on k8s and containerd");

        // k8s and containerd depend only on etcd
        let k8s = workflow.tasks.iter().find(|t| t.name.contains("kubernetes")).unwrap();
        assert_eq!(k8s.dependencies.len(), 1);
        assert!(k8s.dependencies[0].contains("etcd"));

        // WorkflowConfig respects formula.max_parallel
        let config = workflow.config.unwrap();
        assert_eq!(config.max_parallel_tasks, 3);
    }

    #[test]
    fn formula_into_workflow_linear_fallback() {
        // A formula with a single chain (etcd → k8s → cilium) produces linear deps.
        let json = r#"{
            "id": "linear-formula",
            "description": "Linear chain",
            "provider": "test",
            "server": "srv-0",
            "max_parallel": 1,
            "nodes": [
                {"id": "a", "taskserv": {"name": "etcd", "install_mode": "library", "profile": "default", "target_save_path": ""}, "depends_on": [], "parallel": false, "on_error": "Stop", "max_retries": 0},
                {"id": "b", "taskserv": {"name": "kubernetes", "install_mode": "library", "profile": "default", "target_save_path": ""}, "depends_on": [{"node_id": "a", "kind": "OnSuccess"}], "parallel": false, "on_error": "Stop", "max_retries": 0},
                {"id": "c", "taskserv": {"name": "cilium", "install_mode": "library", "profile": "default", "target_save_path": ""}, "depends_on": [{"node_id": "b", "kind": "OnSuccess"}], "parallel": false, "on_error": "Stop", "max_retries": 0}
            ],
            "edges": []
        }"#;
        let formula = Formula::from_json(json).unwrap();
        let workflow = formula
            .into_workflow(FormulaWorkflowConfig {
                operation_cmd: "taskserv create",
                infra_name: "test",
                settings_path: "/s.ncl",
                provisioning_path: "./p",
                environment: Default::default(),
                check_mode: false,
                wait: false,
            })
            .unwrap();

        let c = workflow.tasks.iter().find(|t| t.name.contains("cilium")).unwrap();
        assert_eq!(c.dependencies.len(), 1);
        assert!(c.dependencies[0].contains('b') || c.dependencies[0].contains("kubernetes"));
    }

    // ── WorkspaceComposition tests ──────────────────────────────────────────

    fn make_single_node_formula(id: &str, node_id: &str) -> Formula {
        let json = format!(
            r#"{{
                "id": "{id}",
                "description": "Test formula",
                "provider": "test",
                "server": "srv",
                "max_parallel": 1,
                "nodes": [
                    {{"id": "{node_id}", "taskserv": {{"name": "test-svc", "install_mode": "library", "profile": "default", "target_save_path": ""}}, "depends_on": [], "parallel": false, "on_error": "Stop", "max_retries": 0}}
                ],
                "edges": []
            }}"#
        );
        Formula::from_json(&json).unwrap()
    }

    fn default_cfg<'a>() -> FormulaWorkflowConfig<'a> {
        FormulaWorkflowConfig {
            operation_cmd: "taskserv create",
            infra_name: "wuji",
            settings_path: "/s.ncl",
            provisioning_path: "./p",
            environment: Default::default(),
            check_mode: false,
            wait: false,
        }
    }

    #[test]
    fn workspace_composition_task_namespacing() {
        // Two formulas each with a node named "init" must produce distinct namespaced tasks.
        let f1 = make_single_node_formula("formula-a", "init");
        let f2 = make_single_node_formula("formula-b", "init");

        let comp_json = r#"{
            "workspace": "test-ws",
            "infra": "cluster",
            "composition": {
                "formulas": [
                    {"formula_id": "formula-a", "depends_on": [], "parallel": false},
                    {"formula_id": "formula-b", "depends_on": [], "parallel": false}
                ]
            }
        }"#;

        let comp = WorkspaceComposition::from_json(comp_json).unwrap();
        let wf = comp.into_workflow(vec![f1, f2], default_cfg()).unwrap();

        // 2 formulas × 1 node each = 2 tasks, no health gate
        assert_eq!(wf.tasks.len(), 2, "expected exactly 2 tasks");

        let names: Vec<&str> = wf.tasks.iter().map(|t| t.name.as_str()).collect();
        assert!(names.iter().any(|n| n.starts_with("formula-a::")), "formula-a prefix missing");
        assert!(names.iter().any(|n| n.starts_with("formula-b::")), "formula-b prefix missing");

        // No two tasks share the same name
        let unique: std::collections::HashSet<&&str> = names.iter().collect();
        assert_eq!(unique.len(), names.len(), "task names must be unique after namespacing");
    }

    #[test]
    fn workspace_composition_health_gate_injected() {
        // cp → strg with health gate: expected total = 1 (cp node) + 1 (strg node) + 1 (gate) = 3.
        let cp = make_single_node_formula("wuji-cp-0-formula", "cp-node");
        let strg = make_single_node_formula("wuji-strg-0-formula", "strg-node");

        let comp_json = r#"{
            "workspace": "librecloud_renew",
            "infra": "wuji",
            "composition": {
                "formulas": [
                    {"formula_id": "wuji-cp-0-formula",   "depends_on": [], "parallel": false},
                    {
                        "formula_id": "wuji-strg-0-formula",
                        "depends_on": [{"formula_id": "wuji-cp-0-formula", "condition": "Healthy"}],
                        "parallel": true,
                        "health_gate": {
                            "check_cmd": "kubectl get nodes",
                            "expect": "Ready",
                            "timeout_ms": 120000,
                            "retries": 3
                        }
                    }
                ]
            }
        }"#;

        let comp = WorkspaceComposition::from_json(comp_json).unwrap();
        let wf = comp.into_workflow(vec![cp, strg], default_cfg()).unwrap();

        // Total: 1 (cp-node) + 1 (strg-node) + 1 (health-gate) = 3
        assert_eq!(wf.tasks.len(), 3, "expected cp + strg + 1 health gate");

        let gate = wf
            .tasks
            .iter()
            .find(|t| {
                t.metadata
                    .as_ref()
                    .and_then(|m| m.get("type"))
                    .map(|v| v == "health-gate")
                    .unwrap_or(false)
            })
            .expect("health gate task must exist");

        // Gate depends on cp terminals
        assert!(
            gate.dependencies.iter().any(|d| d.contains("wuji-cp-0-formula")),
            "gate must depend on cp terminals"
        );

        // strg root depends on gate
        let strg_root = wf
            .tasks
            .iter()
            .find(|t| t.name.contains("wuji-strg-0-formula"))
            .expect("strg task must exist");
        assert!(
            strg_root.dependencies.iter().any(|d| d.contains("health-gate")),
            "strg root must depend on health gate"
        );
    }

    #[test]
    fn workspace_composition_direct_wiring_no_gate() {
        // cp → wrkr without health gate: wrkr root depends directly on cp terminals.
        let cp = make_single_node_formula("wuji-cp-0-formula", "cp-node");
        let wrkr = make_single_node_formula("wuji-wrkr-0-formula", "wrkr-node");

        let comp_json = r#"{
            "workspace": "librecloud_renew",
            "infra": "wuji",
            "composition": {
                "formulas": [
                    {"formula_id": "wuji-cp-0-formula",  "depends_on": [], "parallel": false},
                    {"formula_id": "wuji-wrkr-0-formula", "depends_on": [{"formula_id": "wuji-cp-0-formula", "condition": "Healthy"}], "parallel": true}
                ]
            }
        }"#;

        let comp = WorkspaceComposition::from_json(comp_json).unwrap();
        let wf = comp.into_workflow(vec![cp, wrkr], default_cfg()).unwrap();

        // No health gate: 1 (cp) + 1 (wrkr) = 2 tasks only
        assert_eq!(wf.tasks.len(), 2, "no health gate — only formula tasks");

        let wrkr_task = wf
            .tasks
            .iter()
            .find(|t| t.name.contains("wuji-wrkr-0-formula"))
            .unwrap();
        assert!(
            wrkr_task
                .dependencies
                .iter()
                .any(|d| d.contains("wuji-cp-0-formula")),
            "wrkr root must depend directly on cp terminal"
        );
    }

    #[test]
    fn workspace_composition_unknown_formula_id_errors() {
        let cp = make_single_node_formula("wuji-cp-0-formula", "cp-node");

        let comp_json = r#"{
            "workspace": "test",
            "infra": "test",
            "composition": {
                "formulas": [
                    {"formula_id": "wuji-cp-0-formula", "depends_on": [], "parallel": false},
                    {"formula_id": "does-not-exist",    "depends_on": [], "parallel": false}
                ]
            }
        }"#;

        let comp = WorkspaceComposition::from_json(comp_json).unwrap();
        let result = comp.into_workflow(vec![cp], default_cfg());
        assert!(result.is_err(), "unknown formula_id must return Err");
        assert!(
            result.unwrap_err().to_string().contains("does-not-exist"),
            "error must name the unknown id"
        );
    }

    #[test]
    fn workspace_composition_workflow_name() {
        let f = make_single_node_formula("f1", "node");
        let comp_json = r#"{
            "workspace": "librecloud_renew",
            "infra": "wuji",
            "composition": { "formulas": [{"formula_id": "f1", "depends_on": [], "parallel": false}] }
        }"#;
        let comp = WorkspaceComposition::from_json(comp_json).unwrap();
        let wf = comp.into_workflow(vec![f], default_cfg()).unwrap();
        assert_eq!(wf.name, "librecloud_renew::wuji");
    }

    #[test]
    fn component_node_produces_component_install_command() {
        let json = r#"{
            "id": "comp-formula",
            "description": "Component node test",
            "provider": "hetzner",
            "server": "data-0",
            "max_parallel": 1,
            "nodes": [
                {
                    "id": "postgresql",
                    "component": {
                        "name": "postgresql",
                        "mode": "cluster",
                        "namespace": "data",
                        "install_mode": "library",
                        "profile": "default",
                        "target_save_path": ""
                    },
                    "depends_on": [],
                    "parallel": false,
                    "on_error": "Stop",
                    "max_retries": 0
                }
            ],
            "edges": []
        }"#;
        let formula = Formula::from_json(json).unwrap();
        let workflow = formula
            .into_workflow(FormulaWorkflowConfig {
                operation_cmd: "component install",
                infra_name: "libre-daoshi",
                settings_path: "/settings.ncl",
                provisioning_path: "./provisioning",
                environment: std::collections::HashMap::new(),
                check_mode: false,
                wait: false,
            })
            .unwrap();

        assert_eq!(workflow.tasks.len(), 1);
        let task = &workflow.tasks[0];
        assert!(
            task.command.contains("component install"),
            "component node must use 'component install' command, got: {}",
            task.command
        );
        assert!(
            task.args.iter().any(|a| a == "--mode"),
            "component node args must include --mode"
        );
        assert!(
            task.args.iter().any(|a| a == "--namespace"),
            "component node args must include --namespace when set"
        );
        let meta = task.metadata.as_ref().unwrap();
        assert_eq!(meta.get("component").map(String::as_str), Some("postgresql"));
        assert_eq!(meta.get("mode").map(String::as_str), Some("cluster"));
        assert!(!meta.contains_key("taskserv"), "component node must not have taskserv in meta");
    }

    #[test]
    fn node_with_both_taskserv_and_component_errors() {
        let json = r#"{
            "id": "bad-formula",
            "description": "Ambiguous node",
            "provider": "test",
            "server": "srv",
            "max_parallel": 1,
            "nodes": [
                {
                    "id": "ambiguous",
                    "taskserv": {"name": "svc", "install_mode": "library", "profile": "default", "target_save_path": ""},
                    "component": {"name": "svc", "mode": "cluster", "install_mode": "library", "profile": "default", "target_save_path": ""},
                    "depends_on": [],
                    "parallel": false,
                    "on_error": "Stop",
                    "max_retries": 0
                }
            ],
            "edges": []
        }"#;
        let formula = Formula::from_json(json).unwrap();
        let result = formula.into_workflow(FormulaWorkflowConfig {
            operation_cmd: "taskserv create",
            infra_name: "test",
            settings_path: "/s.ncl",
            provisioning_path: "./p",
            environment: Default::default(),
            check_mode: false,
            wait: false,
        });
        assert!(result.is_err(), "both taskserv and component must error");
    }
}
