//! Task configuration file format — `.oo/tasks.yaml`.
//!
//! Defines the types, parser, and validator for user-defined task
//! definitions.  Each task is a named unit of execution that can be a shell
//! command, an ordered sequence, or an unordered parallel composition of
//! other tasks.
//!
//! # File format
//!
//! ```yaml
//! version: 1
//!
//! tasks:
//!   build:
//!     type: shell
//!     queue: build
//!     command: "cargo build --{mode}"
//!     inputs:
//!       mode:
//!         type: select
//!         options: [debug, release]
//!         default: debug
//!
//!   check:
//!     type: parallel
//!     steps:
//!       - build
//!       - lint
//! ```
//!
//! # Validation
//!
//! [`parse_str`] and [`load`] both run a post-parse validation pass that
//! checks:
//!
//! * `version == 1`
//! * All step references point to existing tasks
//! * Step input-overrides reference declared inputs on the target task
//! * Shell command `{placeholder}` interpolations match declared inputs
//! * `select` inputs declare a non-empty `options` list; `default` must be
//!   one of the options
//! * `text` / `number` validation bounds are consistent; regex patterns
//!   compile
//! * No cyclic dependencies in the task graph

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

use once_cell::sync::Lazy;
use regex::Regex;
use serde::Deserialize;

// ---------------------------------------------------------------------------
// Static helpers
// ---------------------------------------------------------------------------

/// Matches `{identifier}` interpolation placeholders inside shell commands.
static INTERPOLATION_RE: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}").unwrap());

// ---------------------------------------------------------------------------
// Top-level file structure
// ---------------------------------------------------------------------------

/// Parsed and validated contents of a `tasks.yaml` file.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TasksFile {
    /// Must be `1`.
    pub version: u32,
    /// Map of task ID → task definition.
    pub tasks: HashMap<String, TaskDef>,
}

impl TasksFile {
    /// Return the definition for `task_id`, if it exists.
    pub fn get(&self, task_id: &str) -> Option<&TaskDef> {
        self.tasks.get(task_id)
    }

    /// Iterate over all `(task_id, task_def)` pairs.
    pub fn iter(&self) -> impl Iterator<Item = (&str, &TaskDef)> {
        self.tasks.iter().map(|(k, v)| (k.as_str(), v))
    }
}

// ---------------------------------------------------------------------------
// Task definitions
// ---------------------------------------------------------------------------

/// A single task definition.  The `type` field selects the variant.
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TaskDef {
    /// Executes a shell command.
    Shell(ShellTaskDef),
    /// Runs steps in insertion order, waiting for each to finish.
    Sequence(CompositeTaskDef),
    /// Runs steps without ordering constraints.
    Parallel(CompositeTaskDef),
}

impl TaskDef {
    /// Returns optional UI metadata, regardless of variant.
    pub fn ui(&self) -> Option<&UiMeta> {
        match self {
            TaskDef::Shell(s) => s.ui.as_ref(),
            TaskDef::Sequence(c) | TaskDef::Parallel(c) => c.ui.as_ref(),
        }
    }
}

// ---------------------------------------------------------------------------
// Shell task
// ---------------------------------------------------------------------------

/// A task that runs a shell command.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ShellTaskDef {
    /// Shell command template; may contain `{input_name}` placeholders.
    pub command: String,
    /// Queue name that serialises this task against other tasks on the same
    /// queue.  If omitted, the task ID is used as its own singleton queue.
    #[serde(default)]
    pub queue: Option<String>,
    /// Cancellation policy when a new task is scheduled onto the same queue.
    #[serde(default)]
    pub cancel: Option<CancelPolicy>,
    /// Declared parameterisable inputs.
    #[serde(default)]
    pub inputs: Option<HashMap<String, InputDef>>,
    /// Optional UI display metadata.
    #[serde(default)]
    pub ui: Option<UiMeta>,
}

impl ShellTaskDef {
    /// Resolve the effective queue name: explicit `queue` field or fall back
    /// to `task_id`.
    pub fn effective_queue<'a>(&'a self, task_id: &'a str) -> &'a str {
        self.queue.as_deref().unwrap_or(task_id)
    }
}

/// When a new task is scheduled on the same queue, how to handle the current
/// one.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CancelPolicy {
    /// Cancel all currently running or queued tasks on the same queue.
    Queue,
    /// Do not cancel; let the existing task finish first.
    None,
}

// ---------------------------------------------------------------------------
// Composite tasks (sequence / parallel)
// ---------------------------------------------------------------------------

/// Shared structure for `sequence` and `parallel` tasks.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CompositeTaskDef {
    /// Ordered list of steps to execute.
    pub steps: Vec<StepRef>,
    /// If `true`, continue executing remaining steps after a step fails.
    /// Defaults to `false`.
    #[serde(default)]
    pub continue_on_error: bool,
    /// Optional UI display metadata.
    #[serde(default)]
    pub ui: Option<UiMeta>,
}

// ---------------------------------------------------------------------------
// Step references
// ---------------------------------------------------------------------------

/// A reference to a task inside a `steps` list.
///
/// Either a plain task ID or a task ID with input overrides:
///
/// ```yaml
/// steps:
///   - build             # simple reference
///   - build:            # reference with overrides
///       mode: release
/// ```
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum StepRef {
    /// Plain task name.
    Simple(String),
    /// `{ task_name: { input_key: value, … } }`
    WithOverrides(HashMap<String, HashMap<String, InputValue>>),
}

impl StepRef {
    /// The task ID this step references.
    pub fn task_id(&self) -> Option<&str> {
        match self {
            Self::Simple(s) => Some(s.as_str()),
            Self::WithOverrides(map) => map.keys().next().map(String::as_str),
        }
    }

    /// Input overrides, if any were specified.
    pub fn overrides(&self) -> Option<&HashMap<String, InputValue>> {
        match self {
            Self::Simple(_) => None,
            Self::WithOverrides(map) => map.values().next(),
        }
    }
}

// ---------------------------------------------------------------------------
// Inputs
// ---------------------------------------------------------------------------

/// A runtime-configurable input value for a task.
#[derive(Debug, Clone)]
pub enum InputValue {
    Bool(bool),
    Number(f64),
    Str(String),
}

impl<'de> serde::Deserialize<'de> for InputValue {
    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        use serde::de::Visitor;
        struct V;
        impl<'de> Visitor<'de> for V {
            type Value = InputValue;
            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
                write!(f, "a boolean, number, or string")
            }
            fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<InputValue, E> {
                Ok(InputValue::Bool(v))
            }
            fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<InputValue, E> {
                Ok(InputValue::Number(v as f64))
            }
            fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<InputValue, E> {
                Ok(InputValue::Number(v as f64))
            }
            fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<InputValue, E> {
                Ok(InputValue::Number(v))
            }
            fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<InputValue, E> {
                Ok(InputValue::Str(v.to_string()))
            }
            fn visit_string<E: serde::de::Error>(self, v: String) -> Result<InputValue, E> {
                Ok(InputValue::Str(v))
            }
        }
        d.deserialize_any(V)
    }
}

impl InputValue {
    /// Coerce to a string suitable for command interpolation.
    pub fn to_string_value(&self) -> String {
        match self {
            InputValue::Str(s) => s.clone(),
            InputValue::Number(n) => n.to_string(),
            InputValue::Bool(b) => b.to_string(),
        }
    }

    /// Return the inner `&str` if this is a string variant.
    pub fn as_str(&self) -> Option<&str> {
        match self {
            InputValue::Str(s) => Some(s.as_str()),
            _ => None,
        }
    }
}

/// Declaration of a single parameterisable input.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InputDef {
    /// Data type of this input.
    #[serde(rename = "type")]
    pub input_type: InputType,
    /// Default value applied when the user does not override the input.
    #[serde(default)]
    pub default: Option<InputValue>,
    /// Hint text shown in UI input fields.
    #[serde(default)]
    pub placeholder: Option<String>,
    /// Allowed values for `select` inputs.
    #[serde(default)]
    pub options: Option<Vec<String>>,
    /// Optional validation constraints.
    #[serde(default)]
    pub validate: Option<ValidationRules>,
}

/// Data type of a task input.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InputType {
    Text,
    Select,
    Boolean,
    Number,
}

/// Validation constraints for an input.
///
/// Only the fields applicable to the input's [`InputType`] are used; the
/// rest are ignored.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ValidationRules {
    /// Minimum string length (for `text` inputs).
    #[serde(default)]
    pub min_length: Option<usize>,
    /// Maximum string length (for `text` inputs).
    #[serde(default)]
    pub max_length: Option<usize>,
    /// Regular expression the value must match (for `text` inputs).
    #[serde(default)]
    pub pattern: Option<String>,
    /// Minimum numeric value (for `number` inputs).
    /// Accepts both integers and floating-point in YAML.
    #[serde(default, deserialize_with = "de_opt_number")]
    pub min: Option<f64>,
    /// Maximum numeric value (for `number` inputs).
    /// Accepts both integers and floating-point in YAML.
    #[serde(default, deserialize_with = "de_opt_number")]
    pub max: Option<f64>,
}

/// Deserialise an optional number field, accepting both YAML integers and
/// floats (e.g. `100` or `1.5`).
fn de_opt_number<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
    use serde::de::Visitor;
    struct V;
    impl<'de> Visitor<'de> for V {
        type Value = f64;
        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
            write!(f, "a number")
        }
        fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<f64, E> {
            Ok(v as f64)
        }
        fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<f64, E> {
            Ok(v as f64)
        }
        fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<f64, E> {
            Ok(v)
        }
    }
    d.deserialize_any(V).map(Some)
}

// ---------------------------------------------------------------------------
// UI metadata
// ---------------------------------------------------------------------------

/// Optional human-readable display information for a task.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UiMeta {
    /// Display title.  Defaults to `"Run {task_id}"`.
    #[serde(default)]
    pub title: Option<String>,
    /// Short description shown in the command palette.
    #[serde(default)]
    pub description: Option<String>,
    /// Grouping category for the command palette.
    #[serde(default)]
    pub category: Option<String>,
}

// ---------------------------------------------------------------------------
// Error type
// ---------------------------------------------------------------------------

/// A validation error produced while loading or validating a `tasks.yaml`.
#[derive(Debug, Clone)]
pub struct ConfigError {
    /// Dot-separated path to the field that caused the error
    /// (e.g. `"tasks.build.inputs.mode.options"`).
    pub path: String,
    /// Human-readable description of the problem.
    pub message: String,
}

impl ConfigError {
    fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
        Self { path: path.into(), message: message.into() }
    }
}

impl std::fmt::Display for ConfigError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        if self.path.is_empty() {
            write!(f, "{}", self.message)
        } else {
            write!(f, "{}: {}", self.path, self.message)
        }
    }
}

// ---------------------------------------------------------------------------
// Loading
// ---------------------------------------------------------------------------

/// Load and validate `tasks.yaml` from the given path.
///
/// * Returns `Ok(None)` if the file does not exist — a missing tasks file is
///   not an error.
/// * Returns `Ok(Some(file))` if parsing and validation both succeed.
/// * Returns `Err(errors)` with one or more [`ConfigError`]s on failure.
pub fn load(path: &Path) -> Result<Option<TasksFile>, Vec<ConfigError>> {
    let content = match std::fs::read_to_string(path) {
        Ok(s) => s,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(e) => {
            return Err(vec![ConfigError::new(
                "",
                format!("could not read {path:?}: {e}"),
            )]);
        }
    };

    parse_str(&content).map(Some)
}

/// Parse and validate a YAML string containing task configuration.
///
/// Returns `Err(errors)` if either the YAML is malformed or semantic
/// validation fails.
pub fn parse_str(content: &str) -> Result<TasksFile, Vec<ConfigError>> {
    // Run serde_saphyr on a dedicated thread with a larger stack to avoid
    // stack-overflow on platforms (notably Windows) where the main thread
    // stack may be small and serde-generated deserializers are deeply
    // recursive.
    let owned = content.to_string();
    let handle = std::thread::Builder::new()
        .name("task_config_parser".into())
        .stack_size(8 * 1024 * 1024)
        .spawn(move || {
            match serde_saphyr::from_str::<TasksFile>(&owned) {
                Ok(parsed) => {
                    let errors = validate(&parsed);
                    if errors.is_empty() {
                        Ok(parsed)
                    } else {
                        Err(errors)
                    }
                }
                Err(e) => Err(vec![ConfigError::new("", e.to_string())]),
            }
        });

    let handle = match handle {
        Ok(h) => h,
        Err(e) => return Err(vec![ConfigError::new("", format!("failed to spawn parser thread: {}", e))]),
    };

    match handle.join() {
        Ok(res) => res,
        Err(_e) => Err(vec![ConfigError::new("", "parser thread panicked".to_string())]),
    }
}

// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------

fn validate(file: &TasksFile) -> Vec<ConfigError> {
    let mut errors = Vec::new();

    if file.version != 1 {
        errors.push(ConfigError::new(
            "version",
            format!("expected 1, got {}", file.version),
        ));
    }

    for (task_id, task_def) in &file.tasks {
        validate_task(file, task_id, task_def, &mut errors);
    }

    detect_cycles(file, &mut errors);

    errors
}

fn validate_task(
    file: &TasksFile,
    task_id: &str,
    task_def: &TaskDef,
    errors: &mut Vec<ConfigError>,
) {
    match task_def {
        TaskDef::Shell(shell) => validate_shell(task_id, shell, errors),
        TaskDef::Sequence(seq) => validate_composite(file, task_id, &seq.steps, errors),
        TaskDef::Parallel(par) => validate_composite(file, task_id, &par.steps, errors),
    }
}

fn validate_shell(task_id: &str, shell: &ShellTaskDef, errors: &mut Vec<ConfigError>) {
    let inputs = shell.inputs.as_ref();
    if let Some(inputs) = inputs {
        for (input_name, input_def) in inputs {
            validate_input(task_id, input_name, input_def, errors);
        }
    }
    // Always check interpolations — even with no inputs, {foo} in the
    // command is an error (no declared input to fill it).
    let empty = HashMap::new();
    validate_interpolations(task_id, &shell.command, inputs.unwrap_or(&empty), errors);
}

fn validate_composite(
    file: &TasksFile,
    task_id: &str,
    steps: &[StepRef],
    errors: &mut Vec<ConfigError>,
) {
    for (i, step) in steps.iter().enumerate() {
        let path = || format!("tasks.{task_id}.steps[{i}]");

        match step {
            StepRef::Simple(ref_id) => {
                if !file.tasks.contains_key(ref_id) {
                    errors.push(ConfigError::new(
                        path(),
                        format!("references unknown task '{ref_id}'"),
                    ));
                }
            }
            StepRef::WithOverrides(map) => {
                if map.len() != 1 {
                    errors.push(ConfigError::new(
                        path(),
                        format!(
                            "step with overrides must have exactly one task name, found {}",
                            map.len()
                        ),
                    ));
                    continue;
                }

                let (ref_id, overrides) = map.iter().next().unwrap();

                if !file.tasks.contains_key(ref_id) {
                    errors.push(ConfigError::new(
                        path(),
                        format!("references unknown task '{ref_id}'"),
                    ));
                    continue;
                }

                // Validate overrides against declared inputs on the target task.
                if let Some(TaskDef::Shell(shell)) = file.tasks.get(ref_id) {
                    let declared: HashSet<&str> = shell
                        .inputs
                        .as_ref()
                        .map(|m| m.keys().map(String::as_str).collect())
                        .unwrap_or_default();

                    for key in overrides.keys() {
                        if !declared.contains(key.as_str()) {
                            errors.push(ConfigError::new(
                                format!("{}.{ref_id}.{key}", path()),
                                format!("task '{ref_id}' has no input '{key}'"),
                            ));
                        }
                    }
                }
            }
        }
    }
}

fn validate_input(
    task_id: &str,
    input_name: &str,
    input_def: &InputDef,
    errors: &mut Vec<ConfigError>,
) {
    let prefix = format!("tasks.{task_id}.inputs.{input_name}");

    match input_def.input_type {
        InputType::Select => {
            let opts = input_def.options.as_deref().unwrap_or(&[]);
            if opts.is_empty() {
                errors.push(ConfigError::new(
                    format!("{prefix}.options"),
                    "select input must define a non-empty 'options' list",
                ));
            }
            if let Some(default) = &input_def.default
                && let Some(s) = default.as_str()
                    && !opts.is_empty() && !opts.iter().any(|o| o == s) {
                        errors.push(ConfigError::new(
                            format!("{prefix}.default"),
                            format!("default '{s}' is not listed in options"),
                        ));
                    }
        }

        InputType::Text => {
            if let Some(v) = &input_def.validate {
                if let (Some(min), Some(max)) = (v.min_length, v.max_length)
                    && min > max {
                        errors.push(ConfigError::new(
                            format!("{prefix}.validate"),
                            format!("min_length ({min}) must not exceed max_length ({max})"),
                        ));
                    }
                if let Some(pattern) = &v.pattern
                    && let Err(e) = Regex::new(pattern) {
                        errors.push(ConfigError::new(
                            format!("{prefix}.validate.pattern"),
                            format!("invalid regular expression: {e}"),
                        ));
                    }
            }
        }

        InputType::Number => {
            if let Some(v) = &input_def.validate
                && let (Some(min), Some(max)) = (v.min, v.max)
                    && min > max {
                        errors.push(ConfigError::new(
                            format!("{prefix}.validate"),
                            format!("min ({min}) must not exceed max ({max})"),
                        ));
                    }
        }

        InputType::Boolean => {}
    }
}

fn validate_interpolations(
    task_id: &str,
    command: &str,
    inputs: &HashMap<String, InputDef>,
    errors: &mut Vec<ConfigError>,
) {
    for cap in INTERPOLATION_RE.captures_iter(command) {
        let name = &cap[1];
        if !inputs.contains_key(name) {
            errors.push(ConfigError::new(
                format!("tasks.{task_id}.command"),
                format!("references undeclared input '{{{name}}}'; add it to 'inputs'"),
            ));
        }
    }
}

// ---------------------------------------------------------------------------
// Cycle detection (Kahn's algorithm)
// ---------------------------------------------------------------------------

fn detect_cycles(file: &TasksFile, errors: &mut Vec<ConfigError>) {
    // Build adjacency list and in-degrees for existing tasks.
    let mut adj: HashMap<String, Vec<String>> = HashMap::new();
    let mut indeg: HashMap<String, usize> = HashMap::new();

    // Initialize nodes
    for task_id in file.tasks.keys() {
        indeg.insert(task_id.clone(), 0);
        adj.insert(task_id.clone(), Vec::new());
    }

    // Populate adjacency and indegree (only consider dependencies that exist)
    for (task_id, def) in &file.tasks {
        for d in task_step_ids(Some(def)) {
            if file.tasks.contains_key(&d) {
                adj.get_mut(task_id).unwrap().push(d.clone());
                *indeg.get_mut(&d).unwrap() += 1;
            }
        }
    }

    // Kahn's algorithm to detect nodes in cycles without recursion
    let mut queue: VecDeque<String> = indeg
        .iter()
        .filter_map(|(k, &v)| if v == 0 { Some(k.clone()) } else { None })
        .collect();
    let mut removed = 0usize;

    while let Some(node) = queue.pop_front() {
        removed += 1;
        if let Some(neis) = adj.get(&node) {
            for n in neis {
                if let Some(c) = indeg.get_mut(n) {
                    *c -= 1;
                    if *c == 0 {
                        queue.push_back(n.clone());
                    }
                }
            }
        }
    }

    if removed != file.tasks.len() {
        // Remaining nodes are part of cycles
        let mut cycle_nodes: Vec<String> = indeg
            .into_iter()
            .filter_map(|(k, v)| if v > 0 { Some(k) } else { None })
            .collect();
        cycle_nodes.sort();
        let msg = format!("cyclic dependency involving: {}", cycle_nodes.join(" → "));
        let path = format!("tasks.{}", cycle_nodes.first().unwrap_or(&"<unknown>".to_string()));
        errors.push(ConfigError::new(path, msg));
    }
}

/// Collect the task IDs directly referenced by a task's steps.
fn task_step_ids(def: Option<&TaskDef>) -> Vec<String> {
    match def {
        None | Some(TaskDef::Shell(_)) => vec![],
        Some(TaskDef::Sequence(c)) | Some(TaskDef::Parallel(c)) => {
            c.steps.iter().filter_map(|s| s.task_id().map(str::to_owned)).collect()
        }
    }
}

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

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

    // -----------------------------------------------------------------------
    // Helpers
    // -----------------------------------------------------------------------

    fn ok(yaml: &str) -> TasksFile {
        parse_str(yaml).unwrap_or_else(|errs| {
            panic!(
                "expected Ok but got errors:\n{}",
                errs.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("\n")
            )
        })
    }

    fn err(yaml: &str) -> Vec<ConfigError> {
        parse_str(yaml).unwrap_err()
    }

    fn has_error(errors: &[ConfigError], substr: &str) -> bool {
        errors.iter().any(|e| {
            e.message.contains(substr) || e.path.contains(substr)
        })
    }

    // -----------------------------------------------------------------------
    // 1. Minimal valid shell task
    // -----------------------------------------------------------------------

    #[test]
    fn minimal_shell_task() {
        let f = ok(r#"
version: 1
tasks:
  build:
    type: shell
    command: "cargo build"
"#);
        assert_eq!(f.version, 1);
        assert!(matches!(f.tasks.get("build"), Some(TaskDef::Shell(_))));
    }

    // -----------------------------------------------------------------------
    // 2. Full shell task with inputs and UI
    // -----------------------------------------------------------------------

    #[test]
    fn full_shell_task() {
        let f = ok(r#"
version: 1
tasks:
  build:
    type: shell
    queue: build
    cancel: queue
    command: "cargo build --{mode}"
    inputs:
      mode:
        type: select
        options: [debug, release]
        default: debug
    ui:
      title: Build
      description: Compile the workspace
      category: build
"#);
        let TaskDef::Shell(shell) = f.tasks.get("build").unwrap() else {
            panic!("expected shell");
        };
        assert_eq!(shell.command, "cargo build --{mode}");
        assert_eq!(shell.queue.as_deref(), Some("build"));
        let inputs = shell.inputs.as_ref().unwrap();
        let mode = inputs.get("mode").unwrap();
        assert_eq!(mode.input_type, InputType::Select);
        assert_eq!(mode.options.as_ref().unwrap(), &["debug", "release"]);
    }

    // -----------------------------------------------------------------------
    // 3. Sequence task
    // -----------------------------------------------------------------------

    #[test]
    fn sequence_task() {
        let f = ok(r#"
version: 1
tasks:
  build:
    type: shell
    command: cargo build
  test:
    type: shell
    command: cargo test
  ci:
    type: sequence
    steps:
      - build
      - test
"#);
        let TaskDef::Sequence(seq) = f.tasks.get("ci").unwrap() else {
            panic!("expected sequence");
        };
        assert_eq!(seq.steps.len(), 2);
        assert!(!seq.continue_on_error);
        assert_eq!(seq.steps[0].task_id(), Some("build"));
        assert_eq!(seq.steps[1].task_id(), Some("test"));
    }

    // -----------------------------------------------------------------------
    // 4. Parallel task with continue_on_error
    // -----------------------------------------------------------------------

    #[test]
    fn parallel_task_with_continue_on_error() {
        let f = ok(r#"
version: 1
tasks:
  lint:
    type: shell
    command: cargo clippy
  fmt:
    type: shell
    command: cargo fmt --check
  check:
    type: parallel
    continue_on_error: true
    steps:
      - lint
      - fmt
"#);
        let TaskDef::Parallel(par) = f.tasks.get("check").unwrap() else {
            panic!("expected parallel");
        };
        assert!(par.continue_on_error);
        assert_eq!(par.steps.len(), 2);
    }

    // -----------------------------------------------------------------------
    // 5. Step with input overrides
    // -----------------------------------------------------------------------

    #[test]
    fn step_with_input_overrides() {
        let f = ok(r#"
version: 1
tasks:
  build:
    type: shell
    command: "cargo build --{mode}"
    inputs:
      mode:
        type: select
        options: [debug, release]
        default: debug
  deploy:
    type: sequence
    steps:
      - build:
          mode: release
      - build
"#);
        let TaskDef::Sequence(seq) = f.tasks.get("deploy").unwrap() else {
            panic!()
        };
        let StepRef::WithOverrides(map) = &seq.steps[0] else {
            panic!("expected step with overrides");
        };
        let overrides = map.get("build").unwrap();
        assert!(overrides.contains_key("mode"));
        assert_eq!(seq.steps[1].task_id(), Some("build"));
    }

    // -----------------------------------------------------------------------
    // 6. Text input with validation
    // -----------------------------------------------------------------------

    #[test]
    fn text_input_with_validation() {
        let f = ok(r#"
version: 1
tasks:
  test:
    type: shell
    command: "cargo test {filter}"
    inputs:
      filter:
        type: text
        default: ""
        placeholder: "Test name"
        validate:
          min_length: 0
          max_length: 50
          pattern: "^[a-zA-Z0-9_:]*$"
"#);
        let TaskDef::Shell(shell) = f.tasks.get("test").unwrap() else {
            panic!()
        };
        let filter = shell.inputs.as_ref().unwrap().get("filter").unwrap();
        assert_eq!(filter.input_type, InputType::Text);
        let v = filter.validate.as_ref().unwrap();
        assert_eq!(v.max_length, Some(50));
        assert!(v.pattern.is_some());
    }

    // -----------------------------------------------------------------------
    // 7. Number input with min/max
    // -----------------------------------------------------------------------

    #[test]
    fn number_input_with_validation() {
        let f = ok(r#"
version: 1
tasks:
  scale:
    type: shell
    command: "scale --factor {factor}"
    inputs:
      factor:
        type: number
        default: 1
        validate:
          min: 1
          max: 10
"#);
        let TaskDef::Shell(shell) = f.tasks.get("scale").unwrap() else {
            panic!()
        };
        let n = shell.inputs.as_ref().unwrap().get("factor").unwrap();
        assert_eq!(n.input_type, InputType::Number);
        let v = n.validate.as_ref().unwrap();
        assert_eq!(v.min, Some(1.0));
        assert_eq!(v.max, Some(10.0));
    }

    // -----------------------------------------------------------------------
    // 8. Full example from spec
    // -----------------------------------------------------------------------

    #[test]
    fn full_spec_example() {
        let f = ok(r#"
version: 1

tasks:
  build:
    type: shell
    queue: build
    command: "cargo build --{mode}"
    inputs:
      mode:
        type: select
        options: [debug, release]
        default: debug

  test:
    type: shell
    queue: build
    command: "cargo test {filter}"
    inputs:
      filter:
        type: text
        default: ""

  check:
    type: parallel
    steps:
      - build
      - test
    continue_on_error: true

  deploy:
    type: sequence
    steps:
      - build:
          mode: release
      - test
"#);
        assert!(f.tasks.contains_key("build"));
        assert!(f.tasks.contains_key("test"));
        assert!(f.tasks.contains_key("check"));
        assert!(f.tasks.contains_key("deploy"));
    }

    // -----------------------------------------------------------------------
    // ERROR: wrong version
    // -----------------------------------------------------------------------

    #[test]
    fn wrong_version_is_error() {
        let errs = err("version: 2\ntasks: {}");
        assert!(has_error(&errs, "expected 1"));
    }

    // -----------------------------------------------------------------------
    // ERROR: unknown field in shell task (deny_unknown_fields)
    // -----------------------------------------------------------------------

    #[test]
    fn unknown_field_in_shell_is_error() {
        let errs = err(r#"
version: 1
tasks:
  build:
    type: shell
    command: cargo build
    totally_unknown_field: oops
"#);
        assert!(!errs.is_empty(), "expected at least one error");
    }

    // -----------------------------------------------------------------------
    // ERROR: missing required `command` in shell task
    // -----------------------------------------------------------------------

    #[test]
    fn missing_command_is_error() {
        let errs = err(r#"
version: 1
tasks:
  build:
    type: shell
"#);
        assert!(!errs.is_empty());
    }

    // -----------------------------------------------------------------------
    // ERROR: missing `steps` in sequence task
    // -----------------------------------------------------------------------

    #[test]
    fn missing_steps_is_error() {
        let errs = err(r#"
version: 1
tasks:
  ci:
    type: sequence
"#);
        assert!(!errs.is_empty());
    }

    // -----------------------------------------------------------------------
    // ERROR: step references non-existent task
    // -----------------------------------------------------------------------

    #[test]
    fn unknown_step_reference_is_error() {
        let errs = err(r#"
version: 1
tasks:
  ci:
    type: sequence
    steps:
      - build
      - nonexistent
"#);
        assert!(has_error(&errs, "nonexistent") || has_error(&errs, "unknown"));
    }

    // -----------------------------------------------------------------------
    // ERROR: direct cycle A → A
    // -----------------------------------------------------------------------

    #[test]
    fn direct_self_cycle_is_error() {
        let errs = err(r#"
version: 1
tasks:
  a:
    type: sequence
    steps:
      - a
"#);
        assert!(has_error(&errs, "cyclic") || has_error(&errs, "cycle"));
    }

    // -----------------------------------------------------------------------
    // ERROR: two-node cycle A → B → A
    // -----------------------------------------------------------------------

    #[test]
    fn two_node_cycle_is_error() {
        let errs = err(r#"
version: 1
tasks:
  a:
    type: sequence
    steps:
      - b
  b:
    type: sequence
    steps:
      - a
"#);
        assert!(has_error(&errs, "cyclic") || has_error(&errs, "cycle"));
    }

    // -----------------------------------------------------------------------
    // ERROR: indirect cycle A → B → C → A
    // -----------------------------------------------------------------------

    #[test]
    fn indirect_cycle_is_error() {
        let errs = err(r#"
version: 1
tasks:
  a:
    type: sequence
    steps: [b]
  b:
    type: sequence
    steps: [c]
  c:
    type: sequence
    steps: [a]
"#);
        assert!(has_error(&errs, "cyclic") || has_error(&errs, "cycle"));
    }

    // -----------------------------------------------------------------------
    // OK: linear chain with no cycle
    // -----------------------------------------------------------------------

    #[test]
    fn linear_chain_has_no_cycle() {
        ok(r#"
version: 1
tasks:
  a:
    type: shell
    command: echo a
  b:
    type: sequence
    steps: [a]
  c:
    type: sequence
    steps: [b]
"#);
    }

    // -----------------------------------------------------------------------
    // ERROR: select input without options
    // -----------------------------------------------------------------------

    #[test]
    fn select_without_options_is_error() {
        let errs = err(r#"
version: 1
tasks:
  build:
    type: shell
    command: "cargo build --{mode}"
    inputs:
      mode:
        type: select
"#);
        assert!(has_error(&errs, "options") || has_error(&errs, "select"));
    }

    // -----------------------------------------------------------------------
    // ERROR: select default not in options
    // -----------------------------------------------------------------------

    #[test]
    fn select_default_not_in_options_is_error() {
        let errs = err(r#"
version: 1
tasks:
  build:
    type: shell
    command: "cargo build --{mode}"
    inputs:
      mode:
        type: select
        options: [debug, release]
        default: optimised
"#);
        assert!(has_error(&errs, "optimised") || has_error(&errs, "default"));
    }

    // -----------------------------------------------------------------------
    // ERROR: command interpolates undeclared input
    // -----------------------------------------------------------------------

    #[test]
    fn undeclared_interpolation_is_error() {
        let errs = err(r#"
version: 1
tasks:
  test:
    type: shell
    command: "cargo test {ghost}"
"#);
        assert!(has_error(&errs, "ghost") || has_error(&errs, "undeclared"));
    }

    // -----------------------------------------------------------------------
    // OK: declared interpolation succeeds
    // -----------------------------------------------------------------------

    #[test]
    fn declared_interpolation_is_ok() {
        ok(r#"
version: 1
tasks:
  test:
    type: shell
    command: "cargo test {filter}"
    inputs:
      filter:
        type: text
        default: ""
"#);
    }

    // -----------------------------------------------------------------------
    // ERROR: invalid regex in text validate.pattern
    // -----------------------------------------------------------------------

    #[test]
    fn invalid_regex_pattern_is_error() {
        let errs = err(r#"
version: 1
tasks:
  test:
    type: shell
    command: "cargo test {filter}"
    inputs:
      filter:
        type: text
        validate:
          pattern: "[invalid("
"#);
        assert!(has_error(&errs, "regular expression") || has_error(&errs, "pattern"));
    }

    // -----------------------------------------------------------------------
    // ERROR: step override references undeclared input
    // -----------------------------------------------------------------------

    #[test]
    fn step_override_undeclared_input_is_error() {
        let errs = err(r#"
version: 1
tasks:
  build:
    type: shell
    command: cargo build
  deploy:
    type: sequence
    steps:
      - build:
          ghost_input: something
"#);
        assert!(has_error(&errs, "ghost_input") || has_error(&errs, "no input"));
    }

    // -----------------------------------------------------------------------
    // ERROR: min_length > max_length
    // -----------------------------------------------------------------------

    #[test]
    fn inverted_text_length_bounds_is_error() {
        let errs = err(r#"
version: 1
tasks:
  t:
    type: shell
    command: "run {x}"
    inputs:
      x:
        type: text
        validate:
          min_length: 10
          max_length: 5
"#);
        assert!(has_error(&errs, "min_length") || has_error(&errs, "max_length"));
    }

    // -----------------------------------------------------------------------
    // ERROR: numeric min > max
    // -----------------------------------------------------------------------

    #[test]
    fn inverted_number_bounds_is_error() {
        let errs = err(r#"
version: 1
tasks:
  t:
    type: shell
    command: "run {factor}"
    inputs:
      factor:
        type: number
        validate:
          min: 100
          max: 1
"#);
        assert!(has_error(&errs, "min") || has_error(&errs, "max"));
    }

    // -----------------------------------------------------------------------
    // OK: boolean input
    // -----------------------------------------------------------------------

    #[test]
    fn boolean_input() {
        ok(r#"
version: 1
tasks:
  test:
    type: shell
    command: "cargo test {verbose}"
    inputs:
      verbose:
        type: boolean
        default: false
"#);
    }

    // -----------------------------------------------------------------------
    // OK: effective_queue falls back to task_id
    // -----------------------------------------------------------------------

    #[test]
    fn effective_queue_fallback() {
        let f = ok(r#"
version: 1
tasks:
  build:
    type: shell
    command: cargo build
"#);
        let TaskDef::Shell(shell) = f.tasks.get("build").unwrap() else {
            panic!()
        };
        assert_eq!(shell.effective_queue("build"), "build");
    }

    // -----------------------------------------------------------------------
    // OK: explicit queue overrides task_id
    // -----------------------------------------------------------------------

    #[test]
    fn effective_queue_explicit() {
        let f = ok(r#"
version: 1
tasks:
  build:
    type: shell
    queue: compilation
    command: cargo build
"#);
        let TaskDef::Shell(shell) = f.tasks.get("build").unwrap() else {
            panic!()
        };
        assert_eq!(shell.effective_queue("build"), "compilation");
    }
}