//! Validated request structs for MCP write tools.
//!
//! Each struct is populated by `from_json`, which runs field-level validation via
//! the newtypes, then cross-field rules via `garde`. Callers receive either a
//! fully validated struct or a `Vec<ValidationError>` with every problem found.

use garde::Validate;
use serde_json::Value;

use super::error::{ValidationError, format_errors_as_json};
use super::newtypes::{
    AcceptanceCriterion, DependsOnId, Description, StoryId, StoryName, TargetStage,
    check_boilerplate_user_story,
};

// ---------------------------------------------------------------------------
// Cross-field validators (used by garde derive)
// ---------------------------------------------------------------------------

/// Junk-only acceptance-criteria indicators — placeholders agents fill in but
/// that contain no actionable requirement.
const JUNK_AC_MARKERS: &[&str] = &["todo", "tbd", "fixme", "xxx", "???"];

fn validate_acceptance_criteria_nonempty(acs: &[AcceptanceCriterion], _ctx: &()) -> garde::Result {
    if acs.is_empty() {
        return Err(garde::Error::new(
            "acceptance_criteria must contain at least one entry",
        ));
    }
    let all_junk = acs.iter().all(|ac| {
        let lower = ac.as_ref().to_lowercase();
        JUNK_AC_MARKERS.contains(&lower.trim())
    });
    if all_junk {
        return Err(garde::Error::new(
            "acceptance_criteria must contain at least one real entry (not just TODO/TBD/FIXME)",
        ));
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// CreateStoryRequest
// ---------------------------------------------------------------------------

/// Fully validated inputs for the `create_story` MCP tool.
#[derive(Debug, Validate)]
pub struct CreateStoryRequest {
    /// Validated story name.
    #[garde(skip)]
    pub name: StoryName,
    /// Optional user story text.
    #[garde(skip)]
    pub user_story: Option<Description>,
    /// Optional background description.
    #[garde(skip)]
    pub description: Option<Description>,
    /// At least one non-junk acceptance criterion required (garde-enforced).
    #[garde(custom(validate_acceptance_criteria_nonempty))]
    pub acceptance_criteria: Vec<AcceptanceCriterion>,
    /// Optional list of story IDs this story depends on.
    #[garde(skip)]
    pub depends_on: Option<Vec<DependsOnId>>,
}

impl CreateStoryRequest {
    /// Parse and validate a `create_story` JSON argument map.
    ///
    /// Runs all field-level validation and cross-field garde rules in a single
    /// pass. Returns every error found, not just the first.
    pub fn from_json(args: &Value) -> Result<Self, String> {
        let mut errors: Vec<ValidationError> = Vec::new();

        // name (required)
        let name = match args.get("name").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "name".into(),
                });
                None
            }
            Some(raw) => match StoryName::parse(raw) {
                Ok(n) => Some(n),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        // user_story (optional)
        let user_story = match args.get("user_story").and_then(|v| v.as_str()) {
            None => None,
            Some(raw) => match Description::parse("user_story", raw) {
                Ok(d) => {
                    if let Some(boilerplate_err) =
                        check_boilerplate_user_story("user_story", d.as_str())
                    {
                        errors.push(boilerplate_err);
                        None
                    } else {
                        Some(d)
                    }
                }
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        // description (optional)
        let description = match args.get("description").and_then(|v| v.as_str()) {
            None => None,
            Some(raw) => match Description::parse("description", raw) {
                Ok(d) => Some(d),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        // acceptance_criteria (required)
        let acceptance_criteria = match args
            .get("acceptance_criteria")
            .and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok())
        {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "acceptance_criteria".into(),
                });
                None
            }
            Some(raw_acs) => {
                let mut parsed = Vec::new();
                for (i, raw) in raw_acs.iter().enumerate() {
                    let field = format!("acceptance_criteria[{i}]");
                    match AcceptanceCriterion::parse(&field, raw) {
                        Ok(ac) => parsed.push(ac),
                        Err(mut errs) => errors.append(&mut errs),
                    }
                }
                Some(parsed)
            }
        };

        // depends_on (optional)
        let depends_on: Option<Vec<DependsOnId>> =
            match args.get("depends_on").and_then(|v| v.as_array()) {
                None => None,
                Some(arr) => {
                    let mut ids = Vec::new();
                    for (i, val) in arr.iter().enumerate() {
                        let field = format!("depends_on[{i}]");
                        match val.as_u64().map(|n| n as u32) {
                            None => errors.push(ValidationError::InvalidUtf8 {
                                field: field.clone(),
                            }),
                            Some(id) => match DependsOnId::parse(&field, id) {
                                Ok(d) => ids.push(d),
                                Err(mut errs) => errors.append(&mut errs),
                            },
                        }
                    }
                    Some(ids)
                }
            };

        if !errors.is_empty() {
            return Err(format_errors_as_json(&errors));
        }

        let req = CreateStoryRequest {
            name: name.unwrap(),
            user_story,
            description,
            acceptance_criteria: acceptance_criteria.unwrap(),
            depends_on,
        };

        // Cross-field garde validation
        if let Err(report) = req.validate_with(&()) {
            for (_, _field_error) in report.iter() {
                // Map garde errors back to typed ValidationError.
                // The only garde rule here is the AC nonempty/junk check.
                let actual = req.acceptance_criteria.len();
                let all_junk = req.acceptance_criteria.iter().all(|ac| {
                    let lower = ac.as_ref().to_lowercase();
                    JUNK_AC_MARKERS.contains(&lower.trim())
                });
                if all_junk && actual > 0 {
                    errors.push(ValidationError::TooFewItems {
                        field: "acceptance_criteria".into(),
                        min: 1,
                        // Semantic "0 real entries"
                        actual: 0,
                    });
                } else {
                    errors.push(ValidationError::TooFewItems {
                        field: "acceptance_criteria".into(),
                        min: 1,
                        actual,
                    });
                }
            }
            return Err(format_errors_as_json(&errors));
        }

        Ok(req)
    }

    /// Extract validated `depends_on` as a plain `Vec<u32>` for downstream use.
    pub fn depends_on_ids(&self) -> Option<Vec<u32>> {
        self.depends_on
            .as_ref()
            .map(|ids| ids.iter().map(|d| d.get()).collect())
    }
}

// ---------------------------------------------------------------------------
// CreateEpicRequest
// ---------------------------------------------------------------------------

/// Fully validated inputs for the `create_epic` MCP tool.
#[derive(Debug, Validate)]
pub struct CreateEpicRequest {
    /// Validated epic name.
    #[garde(skip)]
    pub name: StoryName,
    /// Validated goal statement.
    #[garde(skip)]
    pub goal: Description,
    /// Optional motivation text.
    #[garde(skip)]
    pub motivation: Option<Description>,
    /// Optional key files text (plain string, minimal validation).
    #[garde(skip)]
    pub key_files: Option<String>,
    /// Optional success criteria list.
    #[garde(skip)]
    pub success_criteria: Option<Vec<AcceptanceCriterion>>,
}

impl CreateEpicRequest {
    /// Parse and validate a `create_epic` JSON argument map.
    pub fn from_json(args: &Value) -> Result<Self, String> {
        let mut errors: Vec<ValidationError> = Vec::new();

        // name (required)
        let name = match args.get("name").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "name".into(),
                });
                None
            }
            Some(raw) => match StoryName::parse(raw) {
                Ok(n) => Some(n),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        // goal (required)
        let goal = match args.get("goal").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "goal".into(),
                });
                None
            }
            Some(raw) => match Description::parse("goal", raw) {
                Ok(d) => Some(d),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        // motivation (optional)
        let motivation = match args.get("motivation").and_then(|v| v.as_str()) {
            None => None,
            Some(raw) => match Description::parse("motivation", raw) {
                Ok(d) => Some(d),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        // key_files (optional, plain string — structural markup, not user prose)
        let key_files = args
            .get("key_files")
            .and_then(|v| v.as_str())
            .map(str::trim)
            .filter(|s| !s.is_empty())
            .map(str::to_string);

        // success_criteria (optional list)
        let success_criteria = match args
            .get("success_criteria")
            .and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok())
        {
            None => None,
            Some(raw_sc) => {
                let mut parsed = Vec::new();
                for (i, raw) in raw_sc.iter().enumerate() {
                    let field = format!("success_criteria[{i}]");
                    match AcceptanceCriterion::parse(&field, raw) {
                        Ok(ac) => parsed.push(ac),
                        Err(mut errs) => errors.append(&mut errs),
                    }
                }
                Some(parsed)
            }
        };

        if !errors.is_empty() {
            return Err(format_errors_as_json(&errors));
        }

        Ok(CreateEpicRequest {
            name: name.unwrap(),
            goal: goal.unwrap(),
            motivation,
            key_files,
            success_criteria,
        })
    }

    /// Extract success criteria as plain strings for downstream use.
    pub fn success_criteria_strings(&self) -> Vec<String> {
        self.success_criteria
            .as_ref()
            .map(|sc| sc.iter().map(|c| c.as_ref().to_string()).collect())
            .unwrap_or_default()
    }
}
// ---------------------------------------------------------------------------
// CreateBugRequest
// ---------------------------------------------------------------------------

/// Fully validated inputs for the `create_bug` MCP tool.
#[derive(Debug, Validate)]
pub struct CreateBugRequest {
    /// Validated bug name.
    #[garde(skip)]
    pub name: StoryName,
    /// Required description of the bug.
    #[garde(skip)]
    pub description: Description,
    /// Steps needed to reproduce the bug.
    #[garde(skip)]
    pub steps_to_reproduce: Description,
    /// What actually happens.
    #[garde(skip)]
    pub actual_result: Description,
    /// What should happen.
    #[garde(skip)]
    pub expected_result: Description,
    /// At least one non-junk acceptance criterion required.
    #[garde(custom(validate_acceptance_criteria_nonempty))]
    pub acceptance_criteria: Vec<AcceptanceCriterion>,
    /// Optional list of story IDs this bug depends on.
    #[garde(skip)]
    pub depends_on: Option<Vec<DependsOnId>>,
}

impl CreateBugRequest {
    /// Parse and validate a `create_bug` JSON argument map.
    pub fn from_json(args: &Value) -> Result<Self, String> {
        let mut errors: Vec<ValidationError> = Vec::new();

        let name = match args.get("name").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "name".into(),
                });
                None
            }
            Some(raw) => match StoryName::parse(raw) {
                Ok(n) => Some(n),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        let description = match args.get("description").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "description".into(),
                });
                None
            }
            Some(raw) => match Description::parse("description", raw) {
                Ok(d) => Some(d),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        let steps_to_reproduce = match args.get("steps_to_reproduce").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "steps_to_reproduce".into(),
                });
                None
            }
            Some(raw) => match Description::parse("steps_to_reproduce", raw) {
                Ok(d) => Some(d),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        let actual_result = match args.get("actual_result").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "actual_result".into(),
                });
                None
            }
            Some(raw) => match Description::parse("actual_result", raw) {
                Ok(d) => Some(d),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        let expected_result = match args.get("expected_result").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "expected_result".into(),
                });
                None
            }
            Some(raw) => match Description::parse("expected_result", raw) {
                Ok(d) => Some(d),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        let acceptance_criteria = match args
            .get("acceptance_criteria")
            .and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok())
        {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "acceptance_criteria".into(),
                });
                None
            }
            Some(raw_acs) => {
                let mut parsed = Vec::new();
                for (i, raw) in raw_acs.iter().enumerate() {
                    let field = format!("acceptance_criteria[{i}]");
                    match AcceptanceCriterion::parse(&field, raw) {
                        Ok(ac) => parsed.push(ac),
                        Err(mut errs) => errors.append(&mut errs),
                    }
                }
                Some(parsed)
            }
        };

        let depends_on: Option<Vec<DependsOnId>> =
            match args.get("depends_on").and_then(|v| v.as_array()) {
                None => None,
                Some(arr) => {
                    let mut ids = Vec::new();
                    for (i, val) in arr.iter().enumerate() {
                        let field = format!("depends_on[{i}]");
                        match val.as_u64().map(|n| n as u32) {
                            None => errors.push(ValidationError::InvalidUtf8 {
                                field: field.clone(),
                            }),
                            Some(id) => match DependsOnId::parse(&field, id) {
                                Ok(d) => ids.push(d),
                                Err(mut errs) => errors.append(&mut errs),
                            },
                        }
                    }
                    Some(ids)
                }
            };

        if !errors.is_empty() {
            return Err(format_errors_as_json(&errors));
        }

        let req = CreateBugRequest {
            name: name.unwrap(),
            description: description.unwrap(),
            steps_to_reproduce: steps_to_reproduce.unwrap(),
            actual_result: actual_result.unwrap(),
            expected_result: expected_result.unwrap(),
            acceptance_criteria: acceptance_criteria.unwrap(),
            depends_on,
        };

        if let Err(report) = req.validate_with(&()) {
            for (_, _) in report.iter() {
                let actual = req.acceptance_criteria.len();
                let all_junk = req.acceptance_criteria.iter().all(|ac| {
                    let lower = ac.as_ref().to_lowercase();
                    JUNK_AC_MARKERS.contains(&lower.trim())
                });
                if all_junk && actual > 0 {
                    errors.push(ValidationError::TooFewItems {
                        field: "acceptance_criteria".into(),
                        min: 1,
                        actual: 0,
                    });
                } else {
                    errors.push(ValidationError::TooFewItems {
                        field: "acceptance_criteria".into(),
                        min: 1,
                        actual,
                    });
                }
            }
            return Err(format_errors_as_json(&errors));
        }

        Ok(req)
    }

    /// Extract validated `depends_on` as a plain `Vec<u32>` for downstream use.
    pub fn depends_on_ids(&self) -> Option<Vec<u32>> {
        self.depends_on
            .as_ref()
            .map(|ids| ids.iter().map(|d| d.get()).collect())
    }

    /// Extract acceptance criteria as plain strings for downstream use.
    pub fn acceptance_criteria_strings(&self) -> Vec<String> {
        self.acceptance_criteria
            .iter()
            .map(|ac| ac.as_ref().to_string())
            .collect()
    }
}

// ---------------------------------------------------------------------------
// CreateRefactorRequest
// ---------------------------------------------------------------------------

/// Fully validated inputs for the `create_refactor` MCP tool.
#[derive(Debug, Validate)]
pub struct CreateRefactorRequest {
    /// Validated refactor name.
    #[garde(skip)]
    pub name: StoryName,
    /// Optional background description.
    #[garde(skip)]
    pub description: Option<Description>,
    /// At least one non-junk acceptance criterion required.
    #[garde(custom(validate_acceptance_criteria_nonempty))]
    pub acceptance_criteria: Vec<AcceptanceCriterion>,
    /// Optional list of story IDs this refactor depends on.
    #[garde(skip)]
    pub depends_on: Option<Vec<DependsOnId>>,
}

impl CreateRefactorRequest {
    /// Parse and validate a `create_refactor` JSON argument map.
    pub fn from_json(args: &Value) -> Result<Self, String> {
        let mut errors: Vec<ValidationError> = Vec::new();

        let name = match args.get("name").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "name".into(),
                });
                None
            }
            Some(raw) => match StoryName::parse(raw) {
                Ok(n) => Some(n),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        let description = match args.get("description").and_then(|v| v.as_str()) {
            None => None,
            Some(raw) => match Description::parse("description", raw) {
                Ok(d) => Some(d),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        let acceptance_criteria = match args
            .get("acceptance_criteria")
            .and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok())
        {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "acceptance_criteria".into(),
                });
                None
            }
            Some(raw_acs) => {
                let mut parsed = Vec::new();
                for (i, raw) in raw_acs.iter().enumerate() {
                    let field = format!("acceptance_criteria[{i}]");
                    match AcceptanceCriterion::parse(&field, raw) {
                        Ok(ac) => parsed.push(ac),
                        Err(mut errs) => errors.append(&mut errs),
                    }
                }
                Some(parsed)
            }
        };

        let depends_on: Option<Vec<DependsOnId>> =
            match args.get("depends_on").and_then(|v| v.as_array()) {
                None => None,
                Some(arr) => {
                    let mut ids = Vec::new();
                    for (i, val) in arr.iter().enumerate() {
                        let field = format!("depends_on[{i}]");
                        match val.as_u64().map(|n| n as u32) {
                            None => errors.push(ValidationError::InvalidUtf8 {
                                field: field.clone(),
                            }),
                            Some(id) => match DependsOnId::parse(&field, id) {
                                Ok(d) => ids.push(d),
                                Err(mut errs) => errors.append(&mut errs),
                            },
                        }
                    }
                    Some(ids)
                }
            };

        if !errors.is_empty() {
            return Err(format_errors_as_json(&errors));
        }

        let req = CreateRefactorRequest {
            name: name.unwrap(),
            description,
            acceptance_criteria: acceptance_criteria.unwrap(),
            depends_on,
        };

        if let Err(report) = req.validate_with(&()) {
            for (_, _) in report.iter() {
                let actual = req.acceptance_criteria.len();
                let all_junk = req.acceptance_criteria.iter().all(|ac| {
                    let lower = ac.as_ref().to_lowercase();
                    JUNK_AC_MARKERS.contains(&lower.trim())
                });
                if all_junk && actual > 0 {
                    errors.push(ValidationError::TooFewItems {
                        field: "acceptance_criteria".into(),
                        min: 1,
                        actual: 0,
                    });
                } else {
                    errors.push(ValidationError::TooFewItems {
                        field: "acceptance_criteria".into(),
                        min: 1,
                        actual,
                    });
                }
            }
            return Err(format_errors_as_json(&errors));
        }

        Ok(req)
    }

    /// Extract validated `depends_on` as a plain `Vec<u32>` for downstream use.
    pub fn depends_on_ids(&self) -> Option<Vec<u32>> {
        self.depends_on
            .as_ref()
            .map(|ids| ids.iter().map(|d| d.get()).collect())
    }

    /// Extract acceptance criteria as plain strings for downstream use.
    pub fn acceptance_criteria_strings(&self) -> Vec<String> {
        self.acceptance_criteria
            .iter()
            .map(|ac| ac.as_ref().to_string())
            .collect()
    }
}

// ---------------------------------------------------------------------------
// CreateSpikeRequest
// ---------------------------------------------------------------------------

/// Fully validated inputs for the `create_spike` MCP tool.
#[derive(Debug, Validate)]
pub struct CreateSpikeRequest {
    /// Validated spike name.
    #[garde(skip)]
    pub name: StoryName,
    /// Optional background description.
    #[garde(skip)]
    pub description: Option<Description>,
    /// At least one non-junk acceptance criterion required.
    #[garde(custom(validate_acceptance_criteria_nonempty))]
    pub acceptance_criteria: Vec<AcceptanceCriterion>,
    /// Optional list of story IDs this spike depends on.
    #[garde(skip)]
    pub depends_on: Option<Vec<DependsOnId>>,
}

impl CreateSpikeRequest {
    /// Parse and validate a `create_spike` JSON argument map.
    pub fn from_json(args: &Value) -> Result<Self, String> {
        let mut errors: Vec<ValidationError> = Vec::new();

        let name = match args.get("name").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "name".into(),
                });
                None
            }
            Some(raw) => match StoryName::parse(raw) {
                Ok(n) => Some(n),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        let description = match args.get("description").and_then(|v| v.as_str()) {
            None => None,
            Some(raw) => match Description::parse("description", raw) {
                Ok(d) => Some(d),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        let acceptance_criteria = match args
            .get("acceptance_criteria")
            .and_then(|v| serde_json::from_value::<Vec<String>>(v.clone()).ok())
        {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "acceptance_criteria".into(),
                });
                None
            }
            Some(raw_acs) => {
                let mut parsed = Vec::new();
                for (i, raw) in raw_acs.iter().enumerate() {
                    let field = format!("acceptance_criteria[{i}]");
                    match AcceptanceCriterion::parse(&field, raw) {
                        Ok(ac) => parsed.push(ac),
                        Err(mut errs) => errors.append(&mut errs),
                    }
                }
                Some(parsed)
            }
        };

        let depends_on: Option<Vec<DependsOnId>> =
            match args.get("depends_on").and_then(|v| v.as_array()) {
                None => None,
                Some(arr) => {
                    let mut ids = Vec::new();
                    for (i, val) in arr.iter().enumerate() {
                        let field = format!("depends_on[{i}]");
                        match val.as_u64().map(|n| n as u32) {
                            None => errors.push(ValidationError::InvalidUtf8 {
                                field: field.clone(),
                            }),
                            Some(id) => match DependsOnId::parse(&field, id) {
                                Ok(d) => ids.push(d),
                                Err(mut errs) => errors.append(&mut errs),
                            },
                        }
                    }
                    Some(ids)
                }
            };

        if !errors.is_empty() {
            return Err(format_errors_as_json(&errors));
        }

        let req = CreateSpikeRequest {
            name: name.unwrap(),
            description,
            acceptance_criteria: acceptance_criteria.unwrap(),
            depends_on,
        };

        if let Err(report) = req.validate_with(&()) {
            for (_, _) in report.iter() {
                let actual = req.acceptance_criteria.len();
                let all_junk = req.acceptance_criteria.iter().all(|ac| {
                    let lower = ac.as_ref().to_lowercase();
                    JUNK_AC_MARKERS.contains(&lower.trim())
                });
                if all_junk && actual > 0 {
                    errors.push(ValidationError::TooFewItems {
                        field: "acceptance_criteria".into(),
                        min: 1,
                        actual: 0,
                    });
                } else {
                    errors.push(ValidationError::TooFewItems {
                        field: "acceptance_criteria".into(),
                        min: 1,
                        actual,
                    });
                }
            }
            return Err(format_errors_as_json(&errors));
        }

        Ok(req)
    }

    /// Extract validated `depends_on` as a plain `Vec<u32>` for downstream use.
    pub fn depends_on_ids(&self) -> Option<Vec<u32>> {
        self.depends_on
            .as_ref()
            .map(|ids| ids.iter().map(|d| d.get()).collect())
    }

    /// Extract acceptance criteria as plain strings for downstream use.
    pub fn acceptance_criteria_strings(&self) -> Vec<String> {
        self.acceptance_criteria
            .iter()
            .map(|ac| ac.as_ref().to_string())
            .collect()
    }
}

// ---------------------------------------------------------------------------
// UpdateStoryRequest
// ---------------------------------------------------------------------------

/// Fully validated inputs for the `update_story` MCP tool (text fields only).
///
/// All fields are optional; callers that supply none receive an empty-but-valid
/// struct.  The `front_matter` routing and CRDT wiring live in the tool handler.
#[derive(Debug)]
pub struct UpdateStoryRequest {
    /// Validated new story name, if provided.
    pub name: Option<StoryName>,
    /// Validated user-story text, if provided.
    pub user_story: Option<Description>,
    /// Validated background description, if provided.
    pub description: Option<Description>,
}

impl UpdateStoryRequest {
    /// Parse and validate the text fields from an `update_story` JSON argument map.
    pub fn from_json(args: &Value) -> Result<Self, String> {
        let mut errors: Vec<ValidationError> = Vec::new();

        // name (optional)
        let name = match args.get("name").and_then(|v| v.as_str()) {
            None => None,
            Some(raw) => match StoryName::parse(raw) {
                Ok(n) => Some(n),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        // user_story (optional)
        let user_story = match args.get("user_story").and_then(|v| v.as_str()) {
            None => None,
            Some(raw) => match Description::parse("user_story", raw) {
                Ok(d) => Some(d),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        // description (optional)
        let description = match args.get("description").and_then(|v| v.as_str()) {
            None => None,
            Some(raw) => match Description::parse("description", raw) {
                Ok(d) => Some(d),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        if !errors.is_empty() {
            return Err(format_errors_as_json(&errors));
        }

        Ok(UpdateStoryRequest {
            name,
            user_story,
            description,
        })
    }
}

// ---------------------------------------------------------------------------
// AddCriterionRequest
// ---------------------------------------------------------------------------

/// Fully validated inputs for the `add_criterion` MCP tool.
#[derive(Debug)]
pub struct AddCriterionRequest {
    /// The validated acceptance criterion text to add.
    pub criterion: AcceptanceCriterion,
}

impl AddCriterionRequest {
    /// Parse and validate an `add_criterion` JSON argument map.
    pub fn from_json(args: &Value) -> Result<Self, String> {
        let mut errors: Vec<ValidationError> = Vec::new();

        let criterion = match args.get("criterion").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "criterion".into(),
                });
                None
            }
            Some(raw) => match AcceptanceCriterion::parse("criterion", raw) {
                Ok(ac) => Some(ac),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        if !errors.is_empty() {
            return Err(format_errors_as_json(&errors));
        }

        Ok(AddCriterionRequest {
            criterion: criterion.unwrap(),
        })
    }
}

// ---------------------------------------------------------------------------
// EditCriterionRequest
// ---------------------------------------------------------------------------

/// Fully validated inputs for the `edit_criterion` MCP tool.
#[derive(Debug)]
pub struct EditCriterionRequest {
    /// The validated replacement text for the criterion.
    pub new_text: AcceptanceCriterion,
}

impl EditCriterionRequest {
    /// Parse and validate an `edit_criterion` JSON argument map.
    pub fn from_json(args: &Value) -> Result<Self, String> {
        let mut errors: Vec<ValidationError> = Vec::new();

        let new_text = match args.get("new_text").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "new_text".into(),
                });
                None
            }
            Some(raw) => match AcceptanceCriterion::parse("new_text", raw) {
                Ok(ac) => Some(ac),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        if !errors.is_empty() {
            return Err(format_errors_as_json(&errors));
        }

        Ok(EditCriterionRequest {
            new_text: new_text.unwrap(),
        })
    }
}
// ---------------------------------------------------------------------------
// MoveStoryRequest
// ---------------------------------------------------------------------------

/// Fully validated inputs for the `move_story` MCP tool.
#[derive(Debug)]
pub struct MoveStoryRequest {
    /// Validated story identifier.
    pub story_id: StoryId,
    /// Validated target pipeline stage.
    pub target_stage: TargetStage,
}

impl MoveStoryRequest {
    /// Parse and validate a `move_story` JSON argument map.
    pub fn from_json(args: &serde_json::Value) -> Result<Self, String> {
        let mut errors: Vec<ValidationError> = Vec::new();

        let story_id = match args.get("story_id").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "story_id".into(),
                });
                None
            }
            Some(raw) => match StoryId::parse(raw) {
                Ok(id) => Some(id),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        let target_stage = match args.get("target_stage").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "target_stage".into(),
                });
                None
            }
            Some(raw) => match TargetStage::parse(raw) {
                Ok(s) => Some(s),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        if !errors.is_empty() {
            return Err(format_errors_as_json(&errors));
        }

        Ok(MoveStoryRequest {
            story_id: story_id.unwrap(),
            target_stage: target_stage.unwrap(),
        })
    }
}

// ---------------------------------------------------------------------------
// MoveStoryToMergeRequest
// ---------------------------------------------------------------------------

/// Fully validated inputs for the `move_story_to_merge` MCP tool.
#[derive(Debug)]
pub struct MoveStoryToMergeRequest {
    /// Validated story identifier.
    pub story_id: StoryId,
    /// Optional agent name override; defaults to `"mergemaster"` if absent.
    pub agent_name: Option<String>,
}

impl MoveStoryToMergeRequest {
    /// Parse and validate a `move_story_to_merge` JSON argument map.
    pub fn from_json(args: &serde_json::Value) -> Result<Self, String> {
        let mut errors: Vec<ValidationError> = Vec::new();

        let story_id = match args.get("story_id").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "story_id".into(),
                });
                None
            }
            Some(raw) => match StoryId::parse(raw) {
                Ok(id) => Some(id),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        let agent_name = match args.get("agent_name").and_then(|v| v.as_str()) {
            None => None,
            Some(raw) => {
                let trimmed = raw.trim();
                if trimmed.is_empty() {
                    errors.push(ValidationError::EmptyAfterTrim {
                        field: "agent_name".into(),
                    });
                    None
                } else {
                    Some(trimmed.to_string())
                }
            }
        };

        if !errors.is_empty() {
            return Err(format_errors_as_json(&errors));
        }

        Ok(MoveStoryToMergeRequest {
            story_id: story_id.unwrap(),
            agent_name,
        })
    }

    /// Return the resolved agent name, defaulting to `"mergemaster"`.
    pub fn resolved_agent_name(&self) -> &str {
        self.agent_name.as_deref().unwrap_or("mergemaster")
    }
}

// ---------------------------------------------------------------------------
// UnblockStoryRequest
// ---------------------------------------------------------------------------

/// Fully validated inputs for the `unblock_story` MCP tool.
#[derive(Debug)]
pub struct UnblockStoryRequest {
    /// Validated story identifier.
    pub story_id: StoryId,
}

impl UnblockStoryRequest {
    /// Parse and validate an `unblock_story` JSON argument map.
    pub fn from_json(args: &serde_json::Value) -> Result<Self, String> {
        let mut errors: Vec<ValidationError> = Vec::new();

        let story_id = match args.get("story_id").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "story_id".into(),
                });
                None
            }
            Some(raw) => match StoryId::parse(raw) {
                Ok(id) => Some(id),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        if !errors.is_empty() {
            return Err(format_errors_as_json(&errors));
        }

        Ok(UnblockStoryRequest {
            story_id: story_id.unwrap(),
        })
    }
}

// ---------------------------------------------------------------------------
// FreezeStoryRequest
// ---------------------------------------------------------------------------

/// Fully validated inputs for the `freeze_story` MCP tool.
#[derive(Debug)]
pub struct FreezeStoryRequest {
    /// Validated story identifier.
    pub story_id: StoryId,
}

impl FreezeStoryRequest {
    /// Parse and validate a `freeze_story` JSON argument map.
    pub fn from_json(args: &serde_json::Value) -> Result<Self, String> {
        let mut errors: Vec<ValidationError> = Vec::new();

        let story_id = match args.get("story_id").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "story_id".into(),
                });
                None
            }
            Some(raw) => match StoryId::parse(raw) {
                Ok(id) => Some(id),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        if !errors.is_empty() {
            return Err(format_errors_as_json(&errors));
        }

        Ok(FreezeStoryRequest {
            story_id: story_id.unwrap(),
        })
    }
}

// ---------------------------------------------------------------------------
// ConvertItemTypeRequest
// ---------------------------------------------------------------------------

/// Fully validated inputs for the `convert_item_type` MCP tool and `convert` chat command.
#[derive(Debug)]
pub struct ConvertItemTypeRequest {
    /// Validated story identifier.
    pub story_id: StoryId,
    /// The target item type.
    pub new_type: crate::io::story_metadata::ItemType,
}

impl ConvertItemTypeRequest {
    /// Parse and validate a `convert_item_type` JSON argument map.
    ///
    /// Required fields: `story_id` (work-item filename stem), `new_type`
    /// (one of `"story"`, `"bug"`, `"spike"`, `"refactor"`, `"epic"`).
    pub fn from_json(args: &serde_json::Value) -> Result<Self, String> {
        let mut errors: Vec<ValidationError> = Vec::new();

        let story_id = match args.get("story_id").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "story_id".into(),
                });
                None
            }
            Some(raw) => match StoryId::parse(raw) {
                Ok(id) => Some(id),
                Err(mut errs) => {
                    errors.append(&mut errs);
                    None
                }
            },
        };

        let new_type = match args.get("new_type").and_then(|v| v.as_str()) {
            None => {
                errors.push(ValidationError::FieldMissing {
                    field: "new_type".into(),
                });
                None
            }
            Some(raw) => match crate::io::story_metadata::ItemType::from_str(raw) {
                Some(t) => Some(t),
                None => {
                    errors.push(ValidationError::InvalidValue {
                        field: "new_type".into(),
                        actual: raw.to_string(),
                        allowed: vec![
                            "story".into(),
                            "bug".into(),
                            "spike".into(),
                            "refactor".into(),
                            "epic".into(),
                        ],
                    });
                    None
                }
            },
        };

        if !errors.is_empty() {
            return Err(format_errors_as_json(&errors));
        }

        Ok(ConvertItemTypeRequest {
            story_id: story_id.unwrap(),
            new_type: new_type.unwrap(),
        })
    }
}

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

    #[test]
    fn workspace_garde_dependency_pinned_to_0_23() {
        let root_cargo_toml = concat!(env!("CARGO_MANIFEST_DIR"), "/../Cargo.toml");
        let contents = std::fs::read_to_string(root_cargo_toml).unwrap();
        assert!(
            contents.contains("garde = { version = \"0.23\""),
            "expected workspace Cargo.toml to pin garde to 0.23, found: {contents}"
        );
    }

    // --- CreateStoryRequest ---

    #[test]
    fn create_story_request_valid_minimal() {
        let args = json!({
            "name": "My Story",
            "acceptance_criteria": ["It works"]
        });
        let req = CreateStoryRequest::from_json(&args).unwrap();
        assert_eq!(req.name.as_ref(), "My Story");
        assert_eq!(req.acceptance_criteria.len(), 1);
    }

    #[test]
    fn create_story_request_missing_name() {
        let args = json!({"acceptance_criteria": ["AC1"]});
        let err = CreateStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("name"));
    }

    #[test]
    fn create_story_request_missing_acs() {
        let args = json!({"name": "My Story"});
        let err = CreateStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("acceptance_criteria"));
    }

    #[test]
    fn create_story_request_empty_acs() {
        let args = json!({"name": "My Story", "acceptance_criteria": []});
        let err = CreateStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("TooFewItems"));
    }

    #[test]
    fn create_story_request_all_junk_acs() {
        let args = json!({"name": "My Story", "acceptance_criteria": ["TODO", "TBD"]});
        let err = CreateStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("TooFewItems"));
    }

    #[test]
    fn create_story_request_mixed_junk_and_real() {
        let args = json!({
            "name": "My Story",
            "acceptance_criteria": ["TODO", "Real criterion"]
        });
        let req = CreateStoryRequest::from_json(&args).unwrap();
        assert_eq!(req.acceptance_criteria.len(), 2);
    }

    #[test]
    fn create_story_request_grammar_token_in_name() {
        let args = json!({
            "name": "Story </description> bad",
            "acceptance_criteria": ["AC1"]
        });
        let err = CreateStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("AntiGrammarToken"));
    }

    #[test]
    fn create_story_request_grammar_token_in_ac() {
        let args = json!({
            "name": "Valid Name",
            "acceptance_criteria": ["<thinking>bad</thinking>"]
        });
        let err = CreateStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("AntiGrammarToken"));
    }

    #[test]
    fn create_story_request_rejects_boilerplate_user_story() {
        let args = json!({
            "name": "Valid Name",
            "user_story": "As a user, I want to do a thing, so that I get value.",
            "acceptance_criteria": ["AC1"]
        });
        let err = CreateStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("BoilerplateUserStory"));
    }

    #[test]
    fn create_story_request_with_all_optional_fields() {
        let args = json!({
            "name": "Full Story",
            "user_story": "As a user I want this",
            "description": "Background context",
            "acceptance_criteria": ["AC1", "AC2"],
            "depends_on": [1, 2, 3]
        });
        let req = CreateStoryRequest::from_json(&args).unwrap();
        assert_eq!(req.depends_on_ids(), Some(vec![1, 2, 3]));
    }

    #[test]
    fn create_story_request_errors_contain_json() {
        let args = json!({
            "name": "<thinking>bad</thinking>",
            "acceptance_criteria": []
        });
        let err = CreateStoryRequest::from_json(&args).unwrap_err();
        // Errors are JSON, parseable
        let parsed: serde_json::Value = serde_json::from_str(&err).unwrap();
        assert!(parsed.is_array());
    }

    // --- CreateEpicRequest ---

    #[test]
    fn create_epic_request_valid_minimal() {
        let args = json!({
            "name": "My Epic",
            "goal": "Achieve something great"
        });
        let req = CreateEpicRequest::from_json(&args).unwrap();
        assert_eq!(req.name.as_ref(), "My Epic");
        assert_eq!(req.goal.as_str(), "Achieve something great");
    }

    #[test]
    fn create_epic_request_missing_name() {
        let args = json!({"goal": "some goal"});
        let err = CreateEpicRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("name"));
    }

    #[test]
    fn create_epic_request_missing_goal() {
        let args = json!({"name": "Epic"});
        let err = CreateEpicRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("goal"));
    }

    #[test]
    fn create_epic_request_with_success_criteria() {
        let args = json!({
            "name": "My Epic",
            "goal": "Achieve world peace",
            "success_criteria": ["All wars end", "People prosper"]
        });
        let req = CreateEpicRequest::from_json(&args).unwrap();
        let sc = req.success_criteria_strings();
        assert_eq!(sc.len(), 2);
    }

    // --- CreateBugRequest ---

    #[test]
    fn create_bug_request_valid_minimal() {
        let args = json!({
            "name": "Login crash",
            "description": "App crashes on login",
            "steps_to_reproduce": "1. Open app 2. Click login",
            "actual_result": "500 error",
            "expected_result": "Successful login",
            "acceptance_criteria": ["Login succeeds"]
        });
        let req = CreateBugRequest::from_json(&args).unwrap();
        assert_eq!(req.name.as_ref(), "Login crash");
        assert_eq!(req.acceptance_criteria.len(), 1);
        assert_eq!(req.acceptance_criteria_strings(), vec!["Login succeeds"]);
    }

    #[test]
    fn create_bug_request_missing_name() {
        let args = json!({
            "description": "d",
            "steps_to_reproduce": "s",
            "actual_result": "a",
            "expected_result": "e",
            "acceptance_criteria": ["AC"]
        });
        let err = CreateBugRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("name"));
    }

    #[test]
    fn create_bug_request_missing_description() {
        let args = json!({
            "name": "Bug",
            "steps_to_reproduce": "s",
            "actual_result": "a",
            "expected_result": "e",
            "acceptance_criteria": ["AC"]
        });
        let err = CreateBugRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("description"));
    }

    #[test]
    fn create_bug_request_missing_steps_to_reproduce() {
        let args = json!({
            "name": "Bug",
            "description": "d",
            "actual_result": "a",
            "expected_result": "e",
            "acceptance_criteria": ["AC"]
        });
        let err = CreateBugRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("steps_to_reproduce"));
    }

    #[test]
    fn create_bug_request_empty_acs() {
        let args = json!({
            "name": "Bug",
            "description": "d",
            "steps_to_reproduce": "s",
            "actual_result": "a",
            "expected_result": "e",
            "acceptance_criteria": []
        });
        let err = CreateBugRequest::from_json(&args).unwrap_err();
        assert!(err.contains("TooFewItems"));
    }

    #[test]
    fn create_bug_request_all_junk_acs() {
        let args = json!({
            "name": "Bug",
            "description": "d",
            "steps_to_reproduce": "s",
            "actual_result": "a",
            "expected_result": "e",
            "acceptance_criteria": ["TODO", "TBD"]
        });
        let err = CreateBugRequest::from_json(&args).unwrap_err();
        assert!(err.contains("TooFewItems"));
    }

    #[test]
    fn create_bug_request_grammar_token_in_description() {
        let args = json!({
            "name": "Bug",
            "description": "bad <thinking>token</thinking>",
            "steps_to_reproduce": "s",
            "actual_result": "a",
            "expected_result": "e",
            "acceptance_criteria": ["AC"]
        });
        let err = CreateBugRequest::from_json(&args).unwrap_err();
        assert!(err.contains("AntiGrammarToken"));
    }

    #[test]
    fn create_bug_request_with_depends_on() {
        let args = json!({
            "name": "Bug",
            "description": "d",
            "steps_to_reproduce": "s",
            "actual_result": "a",
            "expected_result": "e",
            "acceptance_criteria": ["Fixed"],
            "depends_on": [1, 2]
        });
        let req = CreateBugRequest::from_json(&args).unwrap();
        assert_eq!(req.depends_on_ids(), Some(vec![1, 2]));
    }

    // --- CreateRefactorRequest ---

    #[test]
    fn create_refactor_request_valid_minimal() {
        let args = json!({
            "name": "Clean up auth",
            "acceptance_criteria": ["Code is clean"]
        });
        let req = CreateRefactorRequest::from_json(&args).unwrap();
        assert_eq!(req.name.as_ref(), "Clean up auth");
        assert!(req.description.is_none());
        assert_eq!(req.acceptance_criteria_strings(), vec!["Code is clean"]);
    }

    #[test]
    fn create_refactor_request_missing_name() {
        let args = json!({"acceptance_criteria": ["AC"]});
        let err = CreateRefactorRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("name"));
    }

    #[test]
    fn create_refactor_request_missing_acs() {
        let args = json!({"name": "Refactor"});
        let err = CreateRefactorRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("acceptance_criteria"));
    }

    #[test]
    fn create_refactor_request_empty_acs() {
        let args = json!({"name": "Refactor", "acceptance_criteria": []});
        let err = CreateRefactorRequest::from_json(&args).unwrap_err();
        assert!(err.contains("TooFewItems"));
    }

    #[test]
    fn create_refactor_request_all_junk_acs() {
        let args = json!({"name": "Refactor", "acceptance_criteria": ["todo", "tbd"]});
        let err = CreateRefactorRequest::from_json(&args).unwrap_err();
        assert!(err.contains("TooFewItems"));
    }

    #[test]
    fn create_refactor_request_with_optional_description() {
        let args = json!({
            "name": "Refactor auth",
            "description": "Background context",
            "acceptance_criteria": ["AC1"]
        });
        let req = CreateRefactorRequest::from_json(&args).unwrap();
        assert!(req.description.is_some());
    }

    #[test]
    fn create_refactor_request_grammar_token_in_name() {
        let args = json!({
            "name": "Refactor </description>",
            "acceptance_criteria": ["AC"]
        });
        let err = CreateRefactorRequest::from_json(&args).unwrap_err();
        assert!(err.contains("AntiGrammarToken"));
    }

    // --- CreateSpikeRequest ---

    #[test]
    fn create_spike_request_valid_minimal() {
        let args = json!({
            "name": "Compare encoders",
            "acceptance_criteria": ["Findings documented"]
        });
        let req = CreateSpikeRequest::from_json(&args).unwrap();
        assert_eq!(req.name.as_ref(), "Compare encoders");
        assert!(req.description.is_none());
        assert_eq!(
            req.acceptance_criteria_strings(),
            vec!["Findings documented"]
        );
    }

    #[test]
    fn create_spike_request_missing_name() {
        let args = json!({"acceptance_criteria": ["AC"]});
        let err = CreateSpikeRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("name"));
    }

    #[test]
    fn create_spike_request_missing_acs() {
        let args = json!({"name": "Spike"});
        let err = CreateSpikeRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("acceptance_criteria"));
    }

    #[test]
    fn create_spike_request_empty_acs() {
        let args = json!({"name": "Spike", "acceptance_criteria": []});
        let err = CreateSpikeRequest::from_json(&args).unwrap_err();
        assert!(err.contains("TooFewItems"));
    }

    #[test]
    fn create_spike_request_all_junk_acs() {
        let args = json!({"name": "Spike", "acceptance_criteria": ["TODO", "FIXME"]});
        let err = CreateSpikeRequest::from_json(&args).unwrap_err();
        assert!(err.contains("TooFewItems"));
    }

    #[test]
    fn create_spike_request_with_optional_description() {
        let args = json!({
            "name": "Spike",
            "description": "Some context",
            "acceptance_criteria": ["Findings documented"]
        });
        let req = CreateSpikeRequest::from_json(&args).unwrap();
        assert!(req.description.is_some());
    }

    #[test]
    fn create_spike_request_grammar_token_in_ac() {
        let args = json!({
            "name": "Spike",
            "acceptance_criteria": ["<tool_use>bad</tool_use>"]
        });
        let err = CreateSpikeRequest::from_json(&args).unwrap_err();
        assert!(err.contains("AntiGrammarToken"));
    }

    #[test]
    fn create_spike_request_errors_are_json() {
        let args = json!({
            "name": "<thinking>bad</thinking>",
            "acceptance_criteria": []
        });
        let err = CreateSpikeRequest::from_json(&args).unwrap_err();
        let parsed: serde_json::Value = serde_json::from_str(&err).unwrap();
        assert!(parsed.is_array());
    }
    // --- UpdateStoryRequest ---

    #[test]
    fn update_story_request_all_absent_is_valid() {
        let args = json!({});
        let req = UpdateStoryRequest::from_json(&args).unwrap();
        assert!(req.name.is_none());
        assert!(req.user_story.is_none());
        assert!(req.description.is_none());
    }

    #[test]
    fn update_story_request_valid_name() {
        let args = json!({"name": "Better Name"});
        let req = UpdateStoryRequest::from_json(&args).unwrap();
        assert_eq!(req.name.unwrap().as_ref(), "Better Name");
    }

    #[test]
    fn update_story_request_empty_name_rejected() {
        let args = json!({"name": "   "});
        let err = UpdateStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("EmptyAfterTrim"));
    }

    #[test]
    fn update_story_request_grammar_token_in_name_rejected() {
        let args = json!({"name": "Bad <thinking>name</thinking>"});
        let err = UpdateStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("AntiGrammarToken"));
    }

    #[test]
    fn update_story_request_valid_descriptions() {
        let args = json!({
            "user_story": "As a user I want X",
            "description": "Some background"
        });
        let req = UpdateStoryRequest::from_json(&args).unwrap();
        assert_eq!(req.user_story.unwrap().as_str(), "As a user I want X");
        assert_eq!(req.description.unwrap().as_str(), "Some background");
    }

    #[test]
    fn update_story_request_grammar_token_in_description_rejected() {
        let args = json!({"description": "text </description> more"});
        let err = UpdateStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("AntiGrammarToken"));
    }

    #[test]
    fn update_story_request_errors_are_json() {
        let args = json!({
            "name": "<thinking>bad</thinking>",
            "description": "<tool_use>bad</tool_use>"
        });
        let err = UpdateStoryRequest::from_json(&args).unwrap_err();
        let parsed: serde_json::Value = serde_json::from_str(&err).unwrap();
        assert!(parsed.is_array());
    }

    // --- AddCriterionRequest ---

    #[test]
    fn add_criterion_request_valid() {
        let args = json!({"criterion": "System returns 200 OK"});
        let req = AddCriterionRequest::from_json(&args).unwrap();
        assert_eq!(req.criterion.as_ref(), "System returns 200 OK");
    }

    #[test]
    fn add_criterion_request_missing_criterion() {
        let args = json!({});
        let err = AddCriterionRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("criterion"));
    }

    #[test]
    fn add_criterion_request_empty_criterion_rejected() {
        let args = json!({"criterion": ""});
        let err = AddCriterionRequest::from_json(&args).unwrap_err();
        assert!(err.contains("EmptyAfterTrim"));
    }

    #[test]
    fn add_criterion_request_grammar_token_rejected() {
        let args = json!({"criterion": "<thinking>inject</thinking>"});
        let err = AddCriterionRequest::from_json(&args).unwrap_err();
        assert!(err.contains("AntiGrammarToken"));
    }

    #[test]
    fn add_criterion_request_too_long_rejected() {
        let long = "x".repeat(1001);
        let args = json!({"criterion": long});
        let err = AddCriterionRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldTooLong"));
    }

    // --- EditCriterionRequest ---

    #[test]
    fn edit_criterion_request_valid() {
        let args = json!({"new_text": "Updated criterion text"});
        let req = EditCriterionRequest::from_json(&args).unwrap();
        assert_eq!(req.new_text.as_ref(), "Updated criterion text");
    }

    #[test]
    fn edit_criterion_request_missing_new_text() {
        let args = json!({});
        let err = EditCriterionRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("new_text"));
    }

    #[test]
    fn edit_criterion_request_empty_new_text_rejected() {
        let args = json!({"new_text": "   "});
        let err = EditCriterionRequest::from_json(&args).unwrap_err();
        assert!(err.contains("EmptyAfterTrim"));
    }

    #[test]
    fn edit_criterion_request_grammar_token_rejected() {
        let args = json!({"new_text": "<tool_use>bad</tool_use>"});
        let err = EditCriterionRequest::from_json(&args).unwrap_err();
        assert!(err.contains("AntiGrammarToken"));
    }

    #[test]
    fn edit_criterion_request_too_long_rejected() {
        let long = "y".repeat(1001);
        let args = json!({"new_text": long});
        let err = EditCriterionRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldTooLong"));
    }
    // --- MoveStoryRequest ---

    #[test]
    fn move_story_request_valid() {
        let args = json!({"story_id": "42", "target_stage": "qa"});
        let req = MoveStoryRequest::from_json(&args).unwrap();
        assert_eq!(req.story_id.as_str(), "42");
        assert_eq!(req.target_stage.as_str(), "qa");
    }

    #[test]
    fn move_story_request_valid_with_slug() {
        let args = json!({"story_id": "42_story_foo", "target_stage": "backlog"});
        let req = MoveStoryRequest::from_json(&args).unwrap();
        assert_eq!(req.story_id.as_str(), "42_story_foo");
    }

    #[test]
    fn move_story_request_missing_story_id() {
        let args = json!({"target_stage": "qa"});
        let err = MoveStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("story_id"));
    }

    #[test]
    fn move_story_request_missing_target_stage() {
        let args = json!({"story_id": "42"});
        let err = MoveStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("target_stage"));
    }

    #[test]
    fn move_story_request_invalid_target_stage() {
        let args = json!({"story_id": "42", "target_stage": "archived"});
        let err = MoveStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("InvalidValue"));
        assert!(err.contains("target_stage"));
    }

    #[test]
    fn move_story_request_invalid_story_id() {
        let args = json!({"story_id": "not-a-number", "target_stage": "qa"});
        let err = MoveStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("InvalidCharacter"));
    }

    #[test]
    fn move_story_request_errors_are_json() {
        let args = json!({});
        let err = MoveStoryRequest::from_json(&args).unwrap_err();
        let parsed: serde_json::Value = serde_json::from_str(&err).unwrap();
        assert!(parsed.is_array());
        assert_eq!(parsed.as_array().unwrap().len(), 2);
    }

    // --- MoveStoryToMergeRequest ---

    #[test]
    fn move_story_to_merge_request_valid_minimal() {
        let args = json!({"story_id": "99"});
        let req = MoveStoryToMergeRequest::from_json(&args).unwrap();
        assert_eq!(req.story_id.as_str(), "99");
        assert_eq!(req.resolved_agent_name(), "mergemaster");
    }

    #[test]
    fn move_story_to_merge_request_custom_agent() {
        let args = json!({"story_id": "99", "agent_name": "custom-agent"});
        let req = MoveStoryToMergeRequest::from_json(&args).unwrap();
        assert_eq!(req.resolved_agent_name(), "custom-agent");
    }

    #[test]
    fn move_story_to_merge_request_empty_agent_name() {
        let args = json!({"story_id": "99", "agent_name": "  "});
        let err = MoveStoryToMergeRequest::from_json(&args).unwrap_err();
        assert!(err.contains("EmptyAfterTrim"));
        assert!(err.contains("agent_name"));
    }

    #[test]
    fn move_story_to_merge_request_missing_story_id() {
        let args = json!({});
        let err = MoveStoryToMergeRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("story_id"));
    }

    // --- UnblockStoryRequest ---

    #[test]
    fn unblock_story_request_valid() {
        let args = json!({"story_id": "7"});
        let req = UnblockStoryRequest::from_json(&args).unwrap();
        assert_eq!(req.story_id.as_str(), "7");
        assert_eq!(req.story_id.numeric_prefix(), "7");
    }

    #[test]
    fn unblock_story_request_valid_with_slug() {
        let args = json!({"story_id": "100_some_story"});
        let req = UnblockStoryRequest::from_json(&args).unwrap();
        assert_eq!(req.story_id.numeric_prefix(), "100");
    }

    #[test]
    fn unblock_story_request_missing_story_id() {
        let args = json!({});
        let err = UnblockStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("story_id"));
    }

    #[test]
    fn unblock_story_request_invalid_story_id() {
        let args = json!({"story_id": ""});
        let err = UnblockStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("EmptyAfterTrim"));
    }

    // --- FreezeStoryRequest ---

    #[test]
    fn freeze_story_request_valid() {
        let args = json!({"story_id": "55_story_example"});
        let req = FreezeStoryRequest::from_json(&args).unwrap();
        assert_eq!(req.story_id.as_str(), "55_story_example");
    }

    #[test]
    fn freeze_story_request_missing_story_id() {
        let args = json!({});
        let err = FreezeStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("FieldMissing"));
        assert!(err.contains("story_id"));
    }

    #[test]
    fn freeze_story_request_grammar_token_in_story_id() {
        let args = json!({"story_id": "<thinking>42</thinking>"});
        let err = FreezeStoryRequest::from_json(&args).unwrap_err();
        assert!(err.contains("AntiGrammarToken"));
    }

    #[test]
    fn freeze_story_request_errors_are_json() {
        let args = json!({"story_id": ""});
        let err = FreezeStoryRequest::from_json(&args).unwrap_err();
        let parsed: serde_json::Value = serde_json::from_str(&err).unwrap();
        assert!(parsed.is_array());
    }
}
