//! Validated input newtypes for MCP write tools.
//!
//! Each newtype's inner value is guaranteed valid once constructed — the only
//! public constructors run the full validation pipeline.  Use the associated
//! `parse` or `parse_with_field` methods (which return rich `Vec<ValidationError>`)
//! in preference to nutype's lower-level `new()`.

use nutype::nutype;
use regex::Regex;
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;

use super::error::ValidationError;
use super::sanitize;

/// Tool-call grammar fragments that must be rejected in any text field.
///
/// These are hallucination artifacts from the LLM (bug 1001): if they appear
/// in a field value the whole call is malformed bot output, not user content.
const ANTI_GRAMMAR_TOKENS: &[&str] = &[
    "</description>",
    "<parameter name=",
    "<thinking>",
    "</thinking>",
    "<assistant>",
    "</assistant>",
    "<tool_use>",
    "</tool_use>",
    "<tool_result>",
    "</tool_result>",
    "<function_calls>",
    "</function_calls>",
    "<invoke>",
    "</invoke>",
];

/// Maximum length (chars) for a story/epic name.
pub(super) const NAME_MAX_LEN: usize = 200;
/// Maximum length (chars) for a description / goal / motivation field.
pub(super) const DESCRIPTION_MAX_LEN: usize = 4000;
/// Maximum length (chars) for a single acceptance criterion.
pub(super) const AC_MAX_LEN: usize = 1000;

/// Scan `value` for any anti-grammar-token and return errors if found.
fn check_grammar_tokens(field: &str, value: &str) -> Vec<ValidationError> {
    ANTI_GRAMMAR_TOKENS
        .iter()
        .filter(|&&token| value.contains(token))
        .map(|&token| ValidationError::AntiGrammarToken {
            field: field.to_string(),
            token: token.to_string(),
        })
        .collect()
}

/// Matches the classic "As a …, I want …, so that …" user-story template,
/// case-insensitively and tolerant of punctuation/whitespace variation between
/// the three clauses (commas, dashes, or nothing at all).
static BOILERPLATE_USER_STORY_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"(?is)\bas\s+an?\b.*?\bi\s+want\b.*?\bso\s+that\b").unwrap());

/// Return a [`ValidationError::BoilerplateUserStory`] if `value` matches the
/// unfilled "As a …, I want …, so that …" template rather than a concrete,
/// plain-language user story.
pub fn check_boilerplate_user_story(field: &str, value: &str) -> Option<ValidationError> {
    if BOILERPLATE_USER_STORY_RE.is_match(value) {
        Some(ValidationError::BoilerplateUserStory {
            field: field.to_string(),
        })
    } else {
        None
    }
}

// ---------------------------------------------------------------------------
// StoryName newtype
// ---------------------------------------------------------------------------

fn is_story_name_nonempty(val: &str) -> bool {
    !val.is_empty()
}

#[nutype(
    sanitize(trim),
    validate(predicate = is_story_name_nonempty),
    derive(Debug, Clone, PartialEq, Serialize, Deserialize, AsRef)
)]
/// A validated, trimmed story or epic name.
pub struct StoryName(String);

impl StoryName {
    /// Parse a raw string as a story name, returning all validation errors found.
    ///
    /// Checks anti-grammar tokens first (on the raw trimmed value), then HTML-sanitises,
    /// then validates length and non-emptiness.
    pub fn parse(raw: &str) -> Result<Self, Vec<ValidationError>> {
        let trimmed = raw.trim();

        // Anti-grammar check on original trimmed value — must precede HTML sanitise
        // because ammonia would strip the tokens before we could detect them.
        let grammar_errors = check_grammar_tokens("name", trimmed);
        if !grammar_errors.is_empty() {
            return Err(grammar_errors);
        }

        let (sanitized, _) = sanitize::sanitize_html("name", trimmed);
        let mut errors = Vec::new();

        if sanitized.is_empty() {
            errors.push(ValidationError::EmptyAfterTrim {
                field: "name".into(),
            });
        } else if sanitized.len() > NAME_MAX_LEN {
            errors.push(ValidationError::FieldTooLong {
                field: "name".into(),
                max: NAME_MAX_LEN,
                actual: sanitized.len(),
            });
        }

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

        // nutype's sanitize(trim) is idempotent; predicate passes since we already checked.
        StoryName::try_new(sanitized).map_err(|_e| {
            vec![ValidationError::EmptyAfterTrim {
                field: "name".into(),
            }]
        })
    }
}

// ---------------------------------------------------------------------------
// AcceptanceCriterion newtype
// ---------------------------------------------------------------------------

fn is_ac_nonempty(val: &str) -> bool {
    !val.is_empty()
}

#[nutype(
    sanitize(trim),
    validate(predicate = is_ac_nonempty),
    derive(Debug, Clone, PartialEq, Serialize, Deserialize, AsRef)
)]
/// A single validated acceptance criterion.
pub struct AcceptanceCriterion(String);

impl AcceptanceCriterion {
    /// Parse a single raw acceptance criterion string, using `field` in error messages.
    pub fn parse(field: &str, raw: &str) -> Result<Self, Vec<ValidationError>> {
        let trimmed = raw.trim();

        let grammar_errors = check_grammar_tokens(field, trimmed);
        if !grammar_errors.is_empty() {
            return Err(grammar_errors);
        }

        let (sanitized, _) = sanitize::sanitize_html(field, trimmed);
        let mut errors = Vec::new();

        if sanitized.is_empty() {
            errors.push(ValidationError::EmptyAfterTrim {
                field: field.to_string(),
            });
        } else if sanitized.len() > AC_MAX_LEN {
            errors.push(ValidationError::FieldTooLong {
                field: field.to_string(),
                max: AC_MAX_LEN,
                actual: sanitized.len(),
            });
        }

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

        AcceptanceCriterion::try_new(sanitized).map_err(|_e| {
            vec![ValidationError::EmptyAfterTrim {
                field: field.to_string(),
            }]
        })
    }
}

// ---------------------------------------------------------------------------
// Description newtype (used for description, user_story, goal, motivation)
// ---------------------------------------------------------------------------

/// A validated trimmed description / goal / motivation text.
///
/// The field name is not embedded in the type; pass it to `parse`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Description(String);

impl Description {
    /// Parse and validate a description field value.
    ///
    /// `field` is used in error messages (e.g. `"description"`, `"goal"`).
    pub fn parse(field: &str, raw: &str) -> Result<Self, Vec<ValidationError>> {
        let trimmed = raw.trim();

        let grammar_errors = check_grammar_tokens(field, trimmed);
        if !grammar_errors.is_empty() {
            return Err(grammar_errors);
        }

        let (sanitized, _) = sanitize::sanitize_html(field, trimmed);
        let mut errors = Vec::new();

        if sanitized.is_empty() {
            errors.push(ValidationError::EmptyAfterTrim {
                field: field.to_string(),
            });
        } else if sanitized.len() > DESCRIPTION_MAX_LEN {
            errors.push(ValidationError::FieldTooLong {
                field: field.to_string(),
                max: DESCRIPTION_MAX_LEN,
                actual: sanitized.len(),
            });
        }

        if !errors.is_empty() {
            Err(errors)
        } else {
            Ok(Description(sanitized))
        }
    }

    /// Return the inner string value.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl AsRef<str> for Description {
    fn as_ref(&self) -> &str {
        &self.0
    }
}

// ---------------------------------------------------------------------------
// DependsOnId newtype
// ---------------------------------------------------------------------------

fn is_nonzero_dep_id(val: &u32) -> bool {
    *val != 0
}

#[nutype(
    validate(predicate = is_nonzero_dep_id),
    derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)
)]
/// A validated non-zero story/item dependency ID.
pub struct DependsOnId(u32);

impl DependsOnId {
    /// Parse a raw `u32` as a dependency ID, using `field` in error messages.
    pub fn parse(field: &str, id: u32) -> Result<Self, Vec<ValidationError>> {
        if id == 0 {
            return Err(vec![ValidationError::OutOfRange {
                field: field.to_string(),
                min: 1,
                max: i64::from(u32::MAX),
                actual: 0,
            }]);
        }
        DependsOnId::try_new(id).map_err(|_| {
            vec![ValidationError::OutOfRange {
                field: field.to_string(),
                min: 1,
                max: i64::from(u32::MAX),
                actual: i64::from(id),
            }]
        })
    }

    /// Return the raw inner value.
    pub fn get(self) -> u32 {
        self.into_inner()
    }
}

// ---------------------------------------------------------------------------
// StoryId newtype
// ---------------------------------------------------------------------------

/// A validated story / work-item identifier.
///
/// Must be non-empty, start with one or more ASCII digits, and contain only
/// ASCII alphanumeric characters or underscores.  Optionally followed by a
/// slug suffix separated by `_` (e.g. `"42_story_foo"`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StoryId(String);

impl StoryId {
    /// Parse and validate a raw story ID string.
    pub fn parse(raw: &str) -> Result<Self, Vec<ValidationError>> {
        let trimmed = raw.trim();

        let grammar_errors = check_grammar_tokens("story_id", trimmed);
        if !grammar_errors.is_empty() {
            return Err(grammar_errors);
        }

        if trimmed.is_empty() {
            return Err(vec![ValidationError::EmptyAfterTrim {
                field: "story_id".into(),
            }]);
        }

        // Must start with at least one ASCII digit.
        let first = trimmed.chars().next().unwrap();
        if !first.is_ascii_digit() {
            return Err(vec![ValidationError::InvalidCharacter {
                field: "story_id".into(),
                ch: first,
                position: 0,
            }]);
        }

        // All characters must be ASCII alphanumeric or underscore.
        for (i, ch) in trimmed.chars().enumerate() {
            if !ch.is_ascii_alphanumeric() && ch != '_' {
                return Err(vec![ValidationError::InvalidCharacter {
                    field: "story_id".into(),
                    ch,
                    position: i,
                }]);
            }
        }

        Ok(StoryId(trimmed.to_string()))
    }

    /// Return the full story ID string (e.g. `"42_story_foo"` or `"42"`).
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Return the numeric prefix only (e.g. `"42"` from `"42_story_foo"`).
    pub fn numeric_prefix(&self) -> &str {
        self.0.split('_').next().unwrap_or(&self.0)
    }
}

// ---------------------------------------------------------------------------
// TargetStage enum
// ---------------------------------------------------------------------------

/// Valid target pipeline stage values for the `move_story` tool.
///
/// Each variant maps to a wire string accepted by `move_story_to_stage`.
#[derive(Debug, Clone, PartialEq)]
pub enum TargetStage {
    /// Move to the backlog stage.
    Backlog,
    /// Move to the current/coding stage.
    Current,
    /// Move to the QA stage.
    Qa,
    /// Move to the merge stage.
    Merge,
    /// Move to the done stage.
    Done,
}

impl TargetStage {
    /// All accepted raw string values.
    pub const ALLOWED: &'static [&'static str] = &["backlog", "current", "qa", "merge", "done"];

    /// Parse a raw target stage string.
    pub fn parse(raw: &str) -> Result<Self, Vec<ValidationError>> {
        match raw.trim() {
            "backlog" => Ok(TargetStage::Backlog),
            "current" => Ok(TargetStage::Current),
            "qa" => Ok(TargetStage::Qa),
            "merge" => Ok(TargetStage::Merge),
            "done" => Ok(TargetStage::Done),
            other => Err(vec![ValidationError::InvalidValue {
                field: "target_stage".into(),
                actual: other.to_string(),
                allowed: Self::ALLOWED.iter().map(|s| s.to_string()).collect(),
            }]),
        }
    }

    /// Return the wire string accepted by `move_story_to_stage`.
    pub fn as_str(&self) -> &str {
        match self {
            TargetStage::Backlog => "backlog",
            TargetStage::Current => "current",
            TargetStage::Qa => "qa",
            TargetStage::Merge => "merge",
            TargetStage::Done => "done",
        }
    }
}

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

    // --- StoryName ---

    #[test]
    fn story_name_rejects_empty() {
        let err = StoryName::parse("").unwrap_err();
        assert!(matches!(err[0], ValidationError::EmptyAfterTrim { .. }));
    }

    #[test]
    fn story_name_rejects_whitespace_only() {
        let err = StoryName::parse("   ").unwrap_err();
        assert!(matches!(err[0], ValidationError::EmptyAfterTrim { .. }));
    }

    #[test]
    fn story_name_accepts_valid() {
        let n = StoryName::parse("My Story").unwrap();
        assert_eq!(n.as_ref(), "My Story");
    }

    #[test]
    fn story_name_trims_whitespace() {
        let n = StoryName::parse("  Trimmed  ").unwrap();
        assert_eq!(n.as_ref(), "Trimmed");
    }

    #[test]
    fn story_name_rejects_too_long() {
        let long = "x".repeat(NAME_MAX_LEN + 1);
        let err = StoryName::parse(&long).unwrap_err();
        assert!(matches!(err[0], ValidationError::FieldTooLong { .. }));
    }

    #[test]
    fn story_name_rejects_grammar_token() {
        let err = StoryName::parse("my story </description> end").unwrap_err();
        assert!(matches!(err[0], ValidationError::AntiGrammarToken { .. }));
    }

    #[test]
    fn story_name_rejects_thinking_token() {
        let err = StoryName::parse("<thinking>hello</thinking>").unwrap_err();
        assert!(matches!(err[0], ValidationError::AntiGrammarToken { .. }));
    }

    #[test]
    fn story_name_strips_html() {
        let n = StoryName::parse("Hello <b>World</b>").unwrap();
        assert!(!n.as_ref().contains('<'));
        assert!(n.as_ref().contains("World"));
    }

    // --- AcceptanceCriterion ---

    #[test]
    fn ac_rejects_empty() {
        let err = AcceptanceCriterion::parse("acceptance_criteria[0]", "").unwrap_err();
        assert!(matches!(err[0], ValidationError::EmptyAfterTrim { .. }));
    }

    #[test]
    fn ac_accepts_valid() {
        let ac = AcceptanceCriterion::parse("acceptance_criteria[0]", "It works").unwrap();
        assert_eq!(ac.as_ref(), "It works");
    }

    #[test]
    fn ac_rejects_grammar_token() {
        let err = AcceptanceCriterion::parse("acceptance_criteria[0]", "<tool_use>bad</tool_use>")
            .unwrap_err();
        assert!(matches!(err[0], ValidationError::AntiGrammarToken { .. }));
    }

    #[test]
    fn ac_rejects_too_long() {
        let long = "x".repeat(AC_MAX_LEN + 1);
        let err = AcceptanceCriterion::parse("acceptance_criteria[0]", &long).unwrap_err();
        assert!(matches!(err[0], ValidationError::FieldTooLong { .. }));
    }

    // --- Description ---

    #[test]
    fn description_rejects_empty() {
        let err = Description::parse("description", "").unwrap_err();
        assert!(matches!(err[0], ValidationError::EmptyAfterTrim { .. }));
    }

    #[test]
    fn description_accepts_valid() {
        let d = Description::parse("goal", "Achieve world peace").unwrap();
        assert_eq!(d.as_str(), "Achieve world peace");
    }

    #[test]
    fn description_rejects_grammar_token() {
        let err = Description::parse("description", "text <parameter name=x> more").unwrap_err();
        assert!(matches!(err[0], ValidationError::AntiGrammarToken { .. }));
    }

    // --- check_boilerplate_user_story ---

    #[test]
    fn boilerplate_user_story_rejects_classic_template() {
        let err = check_boilerplate_user_story(
            "user_story",
            "As a user, I want to log in, so that I can access my account.",
        );
        assert!(matches!(
            err,
            Some(ValidationError::BoilerplateUserStory { .. })
        ));
    }

    #[test]
    fn boilerplate_user_story_rejects_without_commas() {
        let err = check_boilerplate_user_story(
            "user_story",
            "As a developer I want CI so that builds are faster",
        );
        assert!(matches!(
            err,
            Some(ValidationError::BoilerplateUserStory { .. })
        ));
    }

    #[test]
    fn boilerplate_user_story_rejects_case_insensitively() {
        let err = check_boilerplate_user_story(
            "user_story",
            "AS AN admin I WANT reports SO THAT I can audit usage",
        );
        assert!(matches!(
            err,
            Some(ValidationError::BoilerplateUserStory { .. })
        ));
    }

    #[test]
    fn boilerplate_user_story_accepts_concrete_plain_language() {
        let err = check_boilerplate_user_story(
            "user_story",
            "The pipeline dashboard should show test coverage next to each story so \
             QA doesn't have to check CI separately.",
        );
        assert!(err.is_none());
    }

    #[test]
    fn boilerplate_user_story_accepts_partial_phrase_without_so_that() {
        let err = check_boilerplate_user_story("user_story", "As a user I want this");
        assert!(err.is_none());
    }

    // --- DependsOnId ---

    #[test]
    fn depends_on_id_rejects_zero() {
        let err = DependsOnId::parse("depends_on[0]", 0).unwrap_err();
        assert!(matches!(err[0], ValidationError::OutOfRange { .. }));
    }

    #[test]
    fn depends_on_id_accepts_nonzero() {
        let id = DependsOnId::parse("depends_on[0]", 42).unwrap();
        assert_eq!(id.get(), 42);
    }

    // --- StoryId ---

    #[test]
    fn story_id_accepts_numeric_only() {
        let id = StoryId::parse("42").unwrap();
        assert_eq!(id.as_str(), "42");
        assert_eq!(id.numeric_prefix(), "42");
    }

    #[test]
    fn story_id_accepts_numeric_with_slug() {
        let id = StoryId::parse("42_story_foo").unwrap();
        assert_eq!(id.as_str(), "42_story_foo");
        assert_eq!(id.numeric_prefix(), "42");
    }

    #[test]
    fn story_id_rejects_empty() {
        let err = StoryId::parse("").unwrap_err();
        assert!(matches!(err[0], ValidationError::EmptyAfterTrim { .. }));
    }

    #[test]
    fn story_id_rejects_whitespace_only() {
        let err = StoryId::parse("  ").unwrap_err();
        assert!(matches!(err[0], ValidationError::EmptyAfterTrim { .. }));
    }

    #[test]
    fn story_id_rejects_non_digit_start() {
        let err = StoryId::parse("story_42").unwrap_err();
        assert!(matches!(
            err[0],
            ValidationError::InvalidCharacter { position: 0, .. }
        ));
    }

    #[test]
    fn story_id_rejects_invalid_chars() {
        let err = StoryId::parse("42/bad").unwrap_err();
        assert!(matches!(err[0], ValidationError::InvalidCharacter { .. }));
    }

    #[test]
    fn story_id_rejects_grammar_token() {
        let err = StoryId::parse("<thinking>").unwrap_err();
        assert!(matches!(err[0], ValidationError::AntiGrammarToken { .. }));
    }

    #[test]
    fn story_id_trims_whitespace() {
        let id = StoryId::parse("  99  ").unwrap();
        assert_eq!(id.as_str(), "99");
    }

    // --- TargetStage ---

    #[test]
    fn target_stage_accepts_all_valid_values() {
        for &s in TargetStage::ALLOWED {
            assert!(TargetStage::parse(s).is_ok(), "should accept '{s}'");
        }
    }

    #[test]
    fn target_stage_as_str_round_trips() {
        for &s in TargetStage::ALLOWED {
            let stage = TargetStage::parse(s).unwrap();
            assert_eq!(stage.as_str(), s);
        }
    }

    #[test]
    fn target_stage_rejects_invalid() {
        let err = TargetStage::parse("archived").unwrap_err();
        assert!(matches!(
            &err[0],
            ValidationError::InvalidValue { field, .. } if field == "target_stage"
        ));
    }

    #[test]
    fn target_stage_rejects_empty() {
        let err = TargetStage::parse("").unwrap_err();
        assert!(matches!(err[0], ValidationError::InvalidValue { .. }));
    }

    // --- Round-trip serde ---

    #[test]
    fn validation_error_round_trips_json() {
        let err = ValidationError::FieldTooLong {
            field: "description".into(),
            max: 200,
            actual: 287,
        };
        let json = serde_json::to_string(&err).unwrap();
        assert!(json.contains("FieldTooLong"));
        assert!(json.contains("description"));
        let back: ValidationError = serde_json::from_str(&json).unwrap();
        assert_eq!(err, back);
    }
}
