//! Event-based pipeline triggers: register conditions on [`TransitionFired`] events
//! and automatically execute MCP tool calls or agent prompts when they fire.

/// Persistent storage for event triggers backed by a JSON file.
pub mod store;

use crate::pipeline_state::{TransitionFired, event_label, stage_label};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// Predicate for matching a [`TransitionFired`] event.
///
/// Every `Some` field must match for the predicate to pass; `None` fields
/// are wildcards.  All comparisons are case-insensitive.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TriggerPredicate {
    /// Match only transitions for this story (e.g. `"42_my_feature"`).
    pub story_id: Option<String>,
    /// Match only when `fired.before` has this label (e.g. `"Merge"`, `"Coding"`).
    pub from_stage: Option<String>,
    /// Match only when `fired.after` has this label (e.g. `"Done"`, `"MergeFailure"`).
    pub to_stage: Option<String>,
    /// Match only when the event has this label (e.g. `"MergeFailed"`, `"Block"`).
    pub event_kind: Option<String>,
}

impl TriggerPredicate {
    /// Returns `true` if every non-`None` field matches `fired`.
    pub fn matches(&self, fired: &TransitionFired) -> bool {
        if let Some(sid) = &self.story_id
            && !fired.story_id.0.eq_ignore_ascii_case(sid)
        {
            return false;
        }
        if let Some(from) = &self.from_stage
            && !stage_label(&fired.before).eq_ignore_ascii_case(from)
        {
            return false;
        }
        if let Some(to) = &self.to_stage
            && !stage_label(&fired.after).eq_ignore_ascii_case(to)
        {
            return false;
        }
        if let Some(kind) = &self.event_kind
            && !event_label(&fired.event).eq_ignore_ascii_case(kind)
        {
            return false;
        }
        true
    }
}

/// Action to execute when a trigger fires.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TriggerAction {
    /// Call an MCP tool deterministically (no LLM in the loop).
    Mcp {
        method: String,
        args: serde_json::Value,
    },
    /// Spawn a short-lived focused agent with this text as its task prompt.
    Prompt { text: String },
}

/// Whether a trigger fires once then is removed, or fires repeatedly until cancelled.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FireMode {
    /// Remove the trigger after its first match.
    Once,
    /// Keep the trigger active until explicitly cancelled.
    Persistent,
}

/// A registered event trigger.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventTrigger {
    /// Unique identifier (UUIDv4).
    pub id: String,
    /// Predicate applied to every [`TransitionFired`].
    pub predicate: TriggerPredicate,
    /// Action to execute on match.
    pub action: TriggerAction,
    /// Whether this trigger fires once or persists.
    pub mode: FireMode,
    /// When the trigger was registered.
    pub created_at: DateTime<Utc>,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pipeline_state::{PipelineEvent, Stage, StoryId, TransitionFired};

    fn fired(story: &str, before: Stage, after: Stage, event: PipelineEvent) -> TransitionFired {
        TransitionFired {
            story_id: StoryId(story.to_string()),
            before,
            after,
            event,
            at: Utc::now(),
        }
    }

    #[test]
    fn predicate_wildcard_matches_anything() {
        let p = TriggerPredicate {
            story_id: None,
            from_stage: None,
            to_stage: None,
            event_kind: None,
        };
        let f = fired("42_foo", Stage::Backlog, Stage::Qa, PipelineEvent::DepsMet);
        assert!(p.matches(&f));
    }

    #[test]
    fn predicate_story_id_filter() {
        let p = TriggerPredicate {
            story_id: Some("42_foo".to_string()),
            from_stage: None,
            to_stage: None,
            event_kind: None,
        };
        let yes = fired("42_foo", Stage::Backlog, Stage::Qa, PipelineEvent::DepsMet);
        let no = fired("99_bar", Stage::Backlog, Stage::Qa, PipelineEvent::DepsMet);
        assert!(p.matches(&yes));
        assert!(!p.matches(&no));
    }

    #[test]
    fn predicate_to_stage_filter() {
        let p = TriggerPredicate {
            story_id: None,
            from_stage: None,
            to_stage: Some("Done".to_string()),
            event_kind: None,
        };
        let yes = fired(
            "1",
            Stage::Merge {
                feature_branch: crate::pipeline_state::BranchName("b".into()),
                commits_ahead: std::num::NonZeroU32::new(1).unwrap(),
                claim: None,
                retries: 0,
                server_start_time: None,
            },
            Stage::Done {
                merged_at: Utc::now(),
                merge_commit: crate::pipeline_state::GitSha("abc".into()),
            },
            PipelineEvent::MergeSucceeded {
                merge_commit: crate::pipeline_state::GitSha("abc".into()),
            },
        );
        let no = fired("1", Stage::Backlog, Stage::Qa, PipelineEvent::DepsMet);
        assert!(p.matches(&yes));
        assert!(!p.matches(&no));
    }

    #[test]
    fn predicate_event_kind_case_insensitive() {
        let p = TriggerPredicate {
            story_id: None,
            from_stage: None,
            to_stage: None,
            event_kind: Some("depsmet".to_string()),
        };
        let f = fired("1", Stage::Backlog, Stage::Qa, PipelineEvent::DepsMet);
        assert!(p.matches(&f));
    }
}
