//! Pipeline document model and loader.
//!
//! A `mcptest pipe` file is a small YAML document: an ordered list of steps,
//! each naming a tool and its args. This module owns the serde shape, the
//! `on_error` policy parser, and the load-plus-validate path that runs the
//! document through the published JSON Schema before the executor ever
//! touches it. Validating up front means a typo in a step (a missing `tool`,
//! a malformed `on_error`) fails with a schema error pointing at the field
//! rather than a confusing runtime panic mid-run.

use std::path::Path;

use serde::Deserialize;
use serde_json::Value as JsonValue;
use thiserror::Error;

/// Embedded copy of `schemas/pipe/v0.json`, compiled in so a released
/// binary validates pipelines without needing the schema file on disk.
/// Vendored in-crate (kept in sync with the canonical workspace-root copy by
/// `scripts/check-vendored-assets.sh`) so `cargo publish` packages it.
const PIPE_SCHEMA_V0: &str = include_str!("../../schemas/pipe/v0.json");

/// Errors raised while loading or validating a pipeline document.
#[derive(Debug, Error)]
pub enum PipeLoadError {
    /// The file could not be read from disk.
    #[error("could not read pipeline file `{path}`: {source}")]
    Read {
        /// The path we tried to read.
        path: String,
        /// The underlying IO error.
        source: std::io::Error,
    },
    /// The file was not valid YAML.
    #[error("pipeline file `{path}` is not valid YAML: {source}")]
    Yaml {
        /// The offending file.
        path: String,
        /// The YAML parse error.
        source: serde_yaml::Error,
    },
    /// The document parsed but failed JSON Schema validation.
    #[error("pipeline file `{path}` failed schema validation:\n{details}")]
    Schema {
        /// The offending file.
        path: String,
        /// Joined human-readable validation errors.
        details: String,
    },
}

/// A loaded, validated pipeline: an ordered list of steps.
///
/// Held as plain data so the executor and the dry-run planner share one
/// representation. The order of [`Pipeline::steps`] is the execution order.
#[derive(Debug, Clone, Deserialize)]
pub struct Pipeline {
    /// Ordered steps. Renamed from the YAML key `pipe`.
    #[serde(rename = "pipe")]
    pub steps: Vec<Step>,
}

/// One pipeline step: call a tool with args, optionally guarded by `when`.
#[derive(Debug, Clone, Deserialize)]
pub struct Step {
    /// Unique identifier other steps reference as `${id.field}`.
    pub id: String,
    /// MCP tool name to invoke.
    pub tool: String,
    /// Arguments object. String values may carry `${...}` references that
    /// the executor resolves against earlier-step output and CLI vars.
    #[serde(default)]
    pub args: JsonValue,
    /// Optional JSONPath guard. The step runs only when it evaluates truthy.
    #[serde(default)]
    pub when: Option<String>,
    /// Failure policy. Defaults to [`OnError::Stop`].
    #[serde(
        default,
        rename = "on_error",
        deserialize_with = "deserialize_on_error"
    )]
    pub on_error: OnError,
}

/// What the executor does when a step's tool call fails.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OnError {
    /// Abort the pipeline and exit non-zero (default).
    #[default]
    Stop,
    /// Record the failure and continue to the next step.
    Continue,
    /// Retry the step up to `n` times, then fall back to [`OnError::Stop`].
    Retry(u32),
}

impl OnError {
    /// Parse the `on_error` string form. The schema already constrains the
    /// shape, but we re-parse here so the typed model is the source of truth
    /// for the executor and so unit tests do not need a YAML round-trip.
    pub fn parse(raw: &str) -> Result<Self, String> {
        match raw.trim() {
            "stop" => Ok(OnError::Stop),
            "continue" => Ok(OnError::Continue),
            other => {
                if let Some(n) = other.strip_prefix("retry:") {
                    let count: u32 = n
                        .parse()
                        .map_err(|_| format!("invalid retry count in `{raw}`"))?;
                    Ok(OnError::Retry(count))
                } else {
                    Err(format!(
                        "unknown on_error `{raw}` (expected stop, continue, or retry:<n>)"
                    ))
                }
            }
        }
    }
}

fn deserialize_on_error<'de, D>(deserializer: D) -> Result<OnError, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let raw = String::deserialize(deserializer)?;
    OnError::parse(&raw).map_err(serde::de::Error::custom)
}

impl Pipeline {
    /// Load a pipeline file, validate it against the v0 schema, and return
    /// the typed model.
    ///
    /// Validation happens before the typed deserialize so a structural
    /// error (missing `tool`, bad `on_error`) reports against the schema
    /// with a field path, which reads far better than a serde error.
    pub fn load(path: &Path) -> Result<Self, PipeLoadError> {
        let display = path.display().to_string();
        let raw = std::fs::read_to_string(path).map_err(|source| PipeLoadError::Read {
            path: display.clone(),
            source,
        })?;
        Self::from_yaml_str(&raw, &display)
    }

    /// Parse and validate a pipeline from an in-memory YAML string.
    ///
    /// Split out from [`Pipeline::load`] so tests exercise the parse and
    /// validation path without writing a temp file.
    pub fn from_yaml_str(raw: &str, source_name: &str) -> Result<Self, PipeLoadError> {
        let as_yaml: serde_yaml::Value =
            serde_yaml::from_str(raw).map_err(|source| PipeLoadError::Yaml {
                path: source_name.to_string(),
                source,
            })?;
        let as_json: JsonValue =
            serde_json::to_value(&as_yaml).map_err(|e| PipeLoadError::Schema {
                path: source_name.to_string(),
                details: format!("could not convert YAML to JSON for validation: {e}"),
            })?;
        validate_against_schema(&as_json, source_name)?;
        let pipeline: Pipeline =
            serde_json::from_value(as_json).map_err(|e| PipeLoadError::Schema {
                path: source_name.to_string(),
                details: e.to_string(),
            })?;
        Ok(pipeline)
    }
}

fn validate_against_schema(value: &JsonValue, source_name: &str) -> Result<(), PipeLoadError> {
    let schema: JsonValue =
        serde_json::from_str(PIPE_SCHEMA_V0).map_err(|e| PipeLoadError::Schema {
            path: source_name.to_string(),
            details: format!("bundled pipe schema is not valid JSON: {e}"),
        })?;
    let validator = jsonschema::validator_for(&schema).map_err(|e| PipeLoadError::Schema {
        path: source_name.to_string(),
        details: format!("bundled pipe schema did not compile: {e}"),
    })?;
    let errors: Vec<String> = validator
        .iter_errors(value)
        .map(|e| e.to_string())
        .collect();
    if errors.is_empty() {
        Ok(())
    } else {
        Err(PipeLoadError::Schema {
            path: source_name.to_string(),
            details: errors.join("\n"),
        })
    }
}

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

    #[test]
    fn parses_a_minimal_two_step_pipeline() {
        let yaml = r#"
pipe:
  - id: find
    tool: search
    args:
      query: alice
  - id: details
    tool: fetch
    args:
      user_id: ${find.users[0].id}
"#;
        let p = Pipeline::from_yaml_str(yaml, "<test>").unwrap();
        assert_eq!(p.steps.len(), 2);
        assert_eq!(p.steps[0].id, "find");
        assert_eq!(p.steps[1].on_error, OnError::Stop);
    }

    #[test]
    fn on_error_continue_and_retry_parse() {
        let yaml = r#"
pipe:
  - id: a
    tool: t
    on_error: continue
  - id: b
    tool: t
    on_error: retry:3
"#;
        let p = Pipeline::from_yaml_str(yaml, "<test>").unwrap();
        assert_eq!(p.steps[0].on_error, OnError::Continue);
        assert_eq!(p.steps[1].on_error, OnError::Retry(3));
    }

    #[test]
    fn missing_tool_fails_schema_validation() {
        let yaml = r#"
pipe:
  - id: a
"#;
        let err = Pipeline::from_yaml_str(yaml, "<test>").unwrap_err();
        assert!(matches!(err, PipeLoadError::Schema { .. }));
    }

    #[test]
    fn empty_pipe_list_fails_schema_validation() {
        let yaml = "pipe: []\n";
        let err = Pipeline::from_yaml_str(yaml, "<test>").unwrap_err();
        assert!(matches!(err, PipeLoadError::Schema { .. }));
    }

    #[test]
    fn bad_on_error_fails_schema_validation() {
        let yaml = r#"
pipe:
  - id: a
    tool: t
    on_error: explode
"#;
        let err = Pipeline::from_yaml_str(yaml, "<test>").unwrap_err();
        assert!(matches!(err, PipeLoadError::Schema { .. }));
    }

    #[test]
    fn on_error_parse_rejects_garbage() {
        assert!(OnError::parse("nope").is_err());
        assert!(OnError::parse("retry:x").is_err());
        assert_eq!(OnError::parse("retry:5").unwrap(), OnError::Retry(5));
    }

    #[test]
    fn invalid_yaml_reports_yaml_error() {
        let err = Pipeline::from_yaml_str("pipe: [::::", "<test>").unwrap_err();
        assert!(matches!(err, PipeLoadError::Yaml { .. }));
    }
}
