//! Persistent store for registered event triggers, backed by SQLite (`pipeline.db`).
//!
//! Production code uses [`EventTriggerStore::from_pool`] which loads from the
//! `event_triggers` table and persists every mutation via a background writer
//! task.  Unit tests use [`EventTriggerStore::load`] which keeps state in
//! memory without SQLite (no file I/O in tests).

use std::path::PathBuf;
use std::sync::Mutex;

use chrono::Utc;
use serde_json::Value;
use sqlx::SqlitePool;
use tokio::sync::mpsc;

use super::{EventTrigger, FireMode, TriggerAction, TriggerPredicate};

// ── Background writer ────────────────────────────────────────────────────────

/// Commands sent to the background SQLite writer.
enum WriteCmd {
    Upsert(Box<EventTrigger>),
    Delete(String),
}

fn spawn_writer(pool: SqlitePool, mut rx: mpsc::UnboundedReceiver<WriteCmd>) {
    tokio::spawn(async move {
        while let Some(cmd) = rx.recv().await {
            match cmd {
                WriteCmd::Upsert(t) => {
                    let predicate_json = match serde_json::to_string(&t.predicate) {
                        Ok(j) => j,
                        Err(e) => {
                            crate::slog!("[event-triggers] Failed to serialize predicate: {e}");
                            continue;
                        }
                    };
                    let action_json = match serde_json::to_string(&t.action) {
                        Ok(j) => j,
                        Err(e) => {
                            crate::slog!("[event-triggers] Failed to serialize action: {e}");
                            continue;
                        }
                    };
                    let mode_str = match t.mode {
                        FireMode::Once => "once",
                        FireMode::Persistent => "persistent",
                    };
                    let result = sqlx::query(
                        "INSERT INTO event_triggers \
                             (id, predicate_json, action_json, mode, created_at) \
                         VALUES (?1, ?2, ?3, ?4, ?5) \
                         ON CONFLICT(id) DO UPDATE SET \
                             predicate_json = excluded.predicate_json, \
                             action_json    = excluded.action_json, \
                             mode           = excluded.mode",
                    )
                    .bind(&t.id)
                    .bind(&predicate_json)
                    .bind(&action_json)
                    .bind(mode_str)
                    .bind(t.created_at.to_rfc3339())
                    .execute(&pool)
                    .await;
                    if let Err(e) = result {
                        crate::slog!("[event-triggers] DB upsert failed for '{}': {e}", t.id);
                    }
                }
                WriteCmd::Delete(id) => {
                    let result = sqlx::query("DELETE FROM event_triggers WHERE id = ?1")
                        .bind(&id)
                        .execute(&pool)
                        .await;
                    if let Err(e) = result {
                        crate::slog!("[event-triggers] DB delete failed for '{id}': {e}");
                    }
                }
            }
        }
    });
}

// ── Store ────────────────────────────────────────────────────────────────────

/// Internal persistence backend.
enum Persistence {
    /// SQLite-backed (production): mutations are sent via channel.
    Sqlite(mpsc::UnboundedSender<WriteCmd>),
    /// JSON-file-backed (tests): mutations write to disk synchronously.
    Json(PathBuf),
}

/// Persistent store for [`EventTrigger`] entries.
pub struct EventTriggerStore {
    triggers: Mutex<Vec<EventTrigger>>,
    persistence: Persistence,
}

impl EventTriggerStore {
    /// Load from SQLite using the shared pipeline database pool.
    ///
    /// Reads all existing rows from the `event_triggers` table, then spawns a
    /// background task that handles subsequent writes.  This is the production
    /// constructor; tests use [`EventTriggerStore::load`].
    pub async fn from_pool(pool: SqlitePool) -> Result<Self, sqlx::Error> {
        let rows: Vec<(String, String, String, String, String)> = sqlx::query_as(
            "SELECT id, predicate_json, action_json, mode, created_at \
             FROM event_triggers",
        )
        .fetch_all(&pool)
        .await?;

        let mut triggers = Vec::with_capacity(rows.len());
        for (id, pred_json, action_json, mode_str, created_at_str) in rows {
            let predicate: TriggerPredicate = match serde_json::from_str(&pred_json) {
                Ok(p) => p,
                Err(e) => {
                    crate::slog!(
                        "[event-triggers] Skipping malformed predicate for trigger {id}: {e}"
                    );
                    continue;
                }
            };
            let action: TriggerAction = match serde_json::from_str(&action_json) {
                Ok(a) => a,
                Err(e) => {
                    crate::slog!(
                        "[event-triggers] Skipping malformed action for trigger {id}: {e}"
                    );
                    continue;
                }
            };
            let mode = if mode_str == "persistent" {
                FireMode::Persistent
            } else {
                FireMode::Once
            };
            let created_at = created_at_str
                .parse::<chrono::DateTime<Utc>>()
                .unwrap_or_else(|_| Utc::now());

            triggers.push(EventTrigger {
                id,
                predicate,
                action,
                mode,
                created_at,
            });
        }

        let (tx, rx) = mpsc::unbounded_channel();
        spawn_writer(pool, rx);

        Ok(Self {
            triggers: Mutex::new(triggers),
            persistence: Persistence::Sqlite(tx),
        })
    }

    /// Load from a JSON file path.  Used by unit tests; returns an in-memory
    /// store backed by the file for persistence.
    pub fn load(path: PathBuf) -> Self {
        let triggers = if path.exists() {
            std::fs::read_to_string(&path)
                .ok()
                .and_then(|s| serde_json::from_str::<Vec<EventTrigger>>(&s).ok())
                .unwrap_or_default()
        } else {
            Vec::new()
        };
        Self {
            triggers: Mutex::new(triggers),
            persistence: Persistence::Json(path),
        }
    }

    fn persist_json(path: &std::path::Path, triggers: &[EventTrigger]) {
        if let Some(parent) = path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        if let Ok(json) = serde_json::to_string_pretty(triggers) {
            let _ = std::fs::write(path, json);
        }
    }

    /// Register a new trigger and persist it.
    pub fn add(
        &self,
        predicate: TriggerPredicate,
        action: TriggerAction,
        mode: FireMode,
    ) -> Result<EventTrigger, String> {
        let trigger = EventTrigger {
            id: uuid_v4(),
            predicate,
            action,
            mode,
            created_at: Utc::now(),
        };
        let mut triggers = self.triggers.lock().unwrap();
        triggers.push(trigger.clone());

        match &self.persistence {
            Persistence::Sqlite(tx) => {
                let _ = tx.send(WriteCmd::Upsert(Box::new(trigger.clone())));
            }
            Persistence::Json(path) => {
                Self::persist_json(path, &triggers);
            }
        }

        let mode_label = match trigger.mode {
            FireMode::Once => "once",
            FireMode::Persistent => "persistent",
        };
        crate::slog!(
            "[event-triggers] Registered trigger {} | predicate={:?} | mode={mode_label}",
            trigger.id,
            trigger
                .predicate
                .to_stage
                .as_deref()
                .or(trigger.predicate.event_kind.as_deref())
                .unwrap_or("*"),
        );
        Ok(trigger)
    }

    /// Return a snapshot of all registered triggers.
    pub fn list(&self) -> Vec<EventTrigger> {
        self.triggers.lock().unwrap().clone()
    }

    /// Remove the trigger with `id`.  Returns `true` if it was found and removed.
    pub fn cancel(&self, id: &str) -> bool {
        let mut triggers = self.triggers.lock().unwrap();
        let before = triggers.len();
        triggers.retain(|t| t.id != id);
        let removed = triggers.len() < before;
        if removed {
            match &self.persistence {
                Persistence::Sqlite(tx) => {
                    let _ = tx.send(WriteCmd::Delete(id.to_string()));
                }
                Persistence::Json(path) => {
                    Self::persist_json(path, &triggers);
                }
            }
            crate::slog!("[event-triggers] Cancelled trigger {id} | reason=manual");
        }
        removed
    }

    /// Remove all triggers whose ids are in `ids` and return how many were
    /// removed.  Used by the subscriber to delete `Once` triggers after they
    /// fire.
    pub fn cancel_batch(&self, ids: &[String]) -> usize {
        if ids.is_empty() {
            return 0;
        }
        let mut triggers = self.triggers.lock().unwrap();
        let before = triggers.len();
        triggers.retain(|t| !ids.contains(&t.id));
        let removed = before - triggers.len();
        if removed > 0 {
            match &self.persistence {
                Persistence::Sqlite(tx) => {
                    for id in ids {
                        let _ = tx.send(WriteCmd::Delete(id.clone()));
                    }
                }
                Persistence::Json(path) => {
                    Self::persist_json(path, &triggers);
                }
            }
            for id in ids {
                crate::slog!("[event-triggers] Cancelled trigger {id} | reason=once");
            }
        }
        removed
    }
}

/// Generate a random UUIDv4-style identifier.
fn uuid_v4() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .subsec_nanos();
    let hi = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs();
    format!("{hi:016x}-{nanos:08x}-4000-0000-{hi:012x}")
}

/// Parse a [`TriggerAction`] from the raw JSON supplied in an MCP args object.
pub fn parse_action(args: &Value) -> Result<TriggerAction, String> {
    let action_obj = args
        .get("action")
        .ok_or("Missing required argument: action")?;
    let action_type = action_obj
        .get("type")
        .and_then(|v| v.as_str())
        .ok_or("action.type must be a string (\"mcp\" or \"prompt\")")?;

    match action_type {
        "mcp" => {
            let method = action_obj
                .get("method")
                .and_then(|v| v.as_str())
                .ok_or("action.method is required for type=mcp")?
                .to_string();
            let action_args = action_obj
                .get("args")
                .cloned()
                .unwrap_or(serde_json::Value::Object(Default::default()));
            Ok(TriggerAction::Mcp {
                method,
                args: action_args,
            })
        }
        "prompt" => {
            let text = action_obj
                .get("text")
                .and_then(|v| v.as_str())
                .ok_or("action.text is required for type=prompt")?
                .to_string();
            Ok(TriggerAction::Prompt { text })
        }
        other => Err(format!(
            "Unknown action type '{other}'; expected \"mcp\" or \"prompt\""
        )),
    }
}

/// Parse a [`TriggerPredicate`] from the raw JSON supplied in an MCP args object.
pub fn parse_predicate(args: &Value) -> Result<TriggerPredicate, String> {
    let pred = args
        .get("predicate")
        .ok_or("Missing required argument: predicate")?;
    Ok(TriggerPredicate {
        story_id: pred
            .get("story_id")
            .and_then(|v| v.as_str())
            .map(str::to_string),
        from_stage: pred
            .get("from_stage")
            .and_then(|v| v.as_str())
            .map(str::to_string),
        to_stage: pred
            .get("to_stage")
            .and_then(|v| v.as_str())
            .map(str::to_string),
        event_kind: pred
            .get("event_kind")
            .and_then(|v| v.as_str())
            .map(str::to_string),
    })
}

/// Parse a [`FireMode`] from the raw JSON supplied in an MCP args object.
pub fn parse_mode(args: &Value) -> FireMode {
    match args.get("mode").and_then(|v| v.as_str()) {
        Some("persistent") => FireMode::Persistent,
        _ => FireMode::Once,
    }
}

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

    fn tmp_store() -> (TempDir, EventTriggerStore) {
        let dir = TempDir::new().unwrap();
        let store = EventTriggerStore::load(dir.path().join("triggers.json"));
        (dir, store)
    }

    fn basic_pred() -> TriggerPredicate {
        TriggerPredicate {
            story_id: None,
            from_stage: None,
            to_stage: Some("Done".to_string()),
            event_kind: None,
        }
    }

    fn basic_action() -> TriggerAction {
        TriggerAction::Mcp {
            method: "get_pipeline_status".to_string(),
            args: serde_json::json!({}),
        }
    }

    #[test]
    fn store_empty_on_missing_file() {
        let (_dir, store) = tmp_store();
        assert!(store.list().is_empty());
    }

    #[test]
    fn store_add_and_list() {
        let (_dir, store) = tmp_store();
        let t = store
            .add(basic_pred(), basic_action(), FireMode::Once)
            .unwrap();
        let list = store.list();
        assert_eq!(list.len(), 1);
        assert_eq!(list[0].id, t.id);
        assert!(matches!(list[0].mode, FireMode::Once));
    }

    #[test]
    fn store_cancel_removes_entry() {
        let (_dir, store) = tmp_store();
        let t = store
            .add(basic_pred(), basic_action(), FireMode::Persistent)
            .unwrap();
        assert!(store.cancel(&t.id));
        assert!(!store.cancel(&t.id));
        assert!(store.list().is_empty());
    }

    #[test]
    fn store_persists_and_reloads() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("triggers.json");
        let id = {
            let store = EventTriggerStore::load(path.clone());
            let t = store
                .add(basic_pred(), basic_action(), FireMode::Persistent)
                .unwrap();
            t.id.clone()
        };
        let store2 = EventTriggerStore::load(path);
        let list = store2.list();
        assert_eq!(list.len(), 1);
        assert_eq!(list[0].id, id);
    }

    #[tokio::test]
    async fn from_pool_persists_and_reloads() {
        let dir = TempDir::new().unwrap();
        let db_path = dir.path().join("test.db");
        let opts = sqlx::sqlite::SqliteConnectOptions::new()
            .filename(&db_path)
            .create_if_missing(true);
        let pool = sqlx::SqlitePool::connect_with(opts).await.unwrap();
        sqlx::migrate!("./migrations").run(&pool).await.unwrap();

        let id = {
            let store = EventTriggerStore::from_pool(pool.clone()).await.unwrap();
            let t = store
                .add(basic_pred(), basic_action(), FireMode::Persistent)
                .unwrap();
            // Give background writer time to flush.
            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
            t.id.clone()
        };

        let store2 = EventTriggerStore::from_pool(pool.clone()).await.unwrap();
        let list = store2.list();
        assert_eq!(list.len(), 1);
        assert_eq!(list[0].id, id);
    }

    #[test]
    fn store_cancel_batch() {
        let (_dir, store) = tmp_store();
        let a = store
            .add(basic_pred(), basic_action(), FireMode::Once)
            .unwrap();
        let b = store
            .add(basic_pred(), basic_action(), FireMode::Once)
            .unwrap();
        let removed = store.cancel_batch(&[a.id, b.id]);
        assert_eq!(removed, 2);
        assert!(store.list().is_empty());
    }

    #[test]
    fn parse_action_mcp() {
        let args = serde_json::json!({
            "action": { "type": "mcp", "method": "get_version", "args": {} }
        });
        let action = parse_action(&args).unwrap();
        assert!(matches!(action, TriggerAction::Mcp { .. }));
    }

    #[test]
    fn parse_action_prompt() {
        let args = serde_json::json!({
            "action": { "type": "prompt", "text": "investigate the merge failure" }
        });
        let action = parse_action(&args).unwrap();
        assert!(matches!(action, TriggerAction::Prompt { .. }));
    }

    #[test]
    fn parse_action_unknown_type_errors() {
        let args = serde_json::json!({ "action": { "type": "webhook" } });
        assert!(parse_action(&args).is_err());
    }

    #[test]
    fn parse_predicate_all_fields() {
        let args = serde_json::json!({
            "predicate": {
                "story_id": "42_foo",
                "from_stage": "Coding",
                "to_stage": "Done",
                "event_kind": "MergeSucceeded"
            }
        });
        let pred = parse_predicate(&args).unwrap();
        assert_eq!(pred.story_id.as_deref(), Some("42_foo"));
        assert_eq!(pred.from_stage.as_deref(), Some("Coding"));
        assert_eq!(pred.to_stage.as_deref(), Some("Done"));
        assert_eq!(pred.event_kind.as_deref(), Some("MergeSucceeded"));
    }

    #[test]
    fn parse_mode_defaults_to_once() {
        let args = serde_json::json!({});
        assert_eq!(parse_mode(&args), FireMode::Once);
    }

    #[test]
    fn parse_mode_persistent() {
        let args = serde_json::json!({ "mode": "persistent" });
        assert_eq!(parse_mode(&args), FireMode::Persistent);
    }

    /// Regression test: once-mode triggers with a server-restarting action
    /// must be removed from the store BEFORE the action is dispatched.  If
    /// cancellation happens after dispatch, a server restart caused by the
    /// action reloads the persisted store and replays the trigger.
    #[test]
    fn once_mode_restart_trigger_cancelled_before_action() {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("triggers.json");
        let store = EventTriggerStore::load(path.clone());

        let trigger = store
            .add(
                TriggerPredicate {
                    story_id: None,
                    from_stage: None,
                    to_stage: Some("Current".to_string()),
                    event_kind: None,
                },
                TriggerAction::Mcp {
                    method: "server_restarting_action".to_string(),
                    args: serde_json::json!({}),
                },
                FireMode::Once,
            )
            .unwrap();
        let id = trigger.id.clone();

        // Simulate the subscriber: cancel BEFORE the action runs.
        store.cancel(&id);

        // Trigger must be absent immediately (simulates "right after match").
        assert!(
            store.list().is_empty(),
            "once-mode trigger must be absent before its action fires"
        );

        // Simulate server restart: reload from persisted store.
        let reloaded = EventTriggerStore::load(path);
        assert!(
            reloaded.list().is_empty(),
            "trigger must not survive a server restart caused by its own action"
        );
    }
}
