//! Config-driven event pipeline.
//!
//! Loaded from `$NATS_PIPELINES_CONFIG` (or
//! `$SITE_CONTENT_PATH/config/pipelines.ncl` as default).
//!
//! Each `[[pipelines]]` entry maps a trigger to an ordered sequence of steps.
//! The `pipeline_type` field selects the trigger mechanism:
//!
//! | `pipeline_type` | Trigger source                                  |
//! |-----------------|------------------------------------------------|
//! | `nats`          | NATS JetStream subject subscription            |
//! | `ci_cd`         | CI/CD event hook (future: webhook endpoint)    |
//!
//! The Rust executor is entirely generic — trigger values and step
//! behaviours come from the config file, not from compiled code.
//!
//! # Example config (`site/config/pipelines/nats.ncl`)
//!
//! ```nickel
//! {
//!   name          = "content-published",
//!   pipeline_type = "nats",
//!   subject       = "evol.website.prod.content.published",
//!   steps = [
//!     { type = "cache_invalidate" },
//!     { type = "broadcast", kind = "info", message = "New content is available" },
//!     { type = "script", path = "scripts/nats/on-content-published.nu" },
//!   ],
//! }
//! ```

use serde::Deserialize;

/// Root record of `pipelines.ncl` — deserialized directly from the NCL
/// export JSON.  Field names must match the Nickel record keys.
#[derive(Debug, Clone, Deserialize, Default)]
pub struct PipelineConfig {
    #[serde(default)]
    pub pipelines: Vec<Pipeline>,
}

/// One pipeline: a named trigger → ordered list of steps.
#[derive(Debug, Clone, Deserialize)]
pub struct Pipeline {
    /// Human-readable name used in log messages.
    pub name: String,
    /// Trigger discriminator and its type-specific fields.
    #[serde(flatten)]
    pub trigger: PipelineTrigger,
    #[serde(default)]
    pub steps: Vec<PipelineStep>,
}

/// Determines how a pipeline is triggered.
///
/// Serialised as a flat tagged record: the `pipeline_type` key selects the
/// variant and the remaining keys carry variant-specific data.
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "pipeline_type", rename_all = "snake_case")]
pub enum PipelineTrigger {
    /// Subscribe to a NATS subject and execute on every incoming message.
    Nats {
        /// Full NATS subject string (e.g.
        /// `evol.website.prod.content.published`). Wildcards (`*`, `>`)
        /// are valid per NATS subject syntax.
        subject: String,
    },

    /// React to a named CI/CD event (webhook-driven; not yet wired at runtime).
    CiCd {
        /// Event identifier published by the CI/CD system
        /// (e.g. `deploy.completed`, `test.passed`).
        event: String,
    },
}

impl PipelineTrigger {
    /// Returns a stable label for logging — the subject for NATS, the event
    /// name for CI/CD.
    pub fn label(&self) -> &str {
        match self {
            Self::Nats { subject } => subject,
            Self::CiCd { event } => event,
        }
    }
}

/// A single action in a pipeline execution chain.
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PipelineStep {
    /// Flush the in-memory content cache so the next request re-reads from
    /// disk / database.
    CacheInvalidate,

    /// Broadcast a toast notification to all connected WebSocket clients.
    Broadcast {
        /// Toast severity: `"info"` | `"success"` | `"warning"` | `"error"`.
        kind: String,
        /// Message text shown in the toast.
        message: String,
    },

    /// Execute an external script, passing the event as environment variables.
    ///
    /// The executor resolves the interpreter from the file extension
    /// (`.nu` → `nu`, `.sh` → `sh`, other → direct exec).
    ///
    /// The script receives:
    /// - `PIPELINE_EVENT` — the trigger identifier (NATS subject or CI/CD
    ///   event).
    /// - `PIPELINE_PAYLOAD` — the raw message body as a UTF-8 string.
    Script {
        /// Path to the script.  Relative paths are resolved against
        /// `$SITE_CONTENT_PATH` (or the directory containing the pipeline
        /// config file when `$NATS_PIPELINES_CONFIG` is set).
        path: String,
    },

    /// Persist the event as a message in a user's inbox (`user_messages`
    /// table).  Requires the `auth` feature.
    PersistMessage {
        /// How to determine the target user UUID.
        target: MessageTarget,
        /// Sender label shown in the inbox (e.g. `"ci@example.com"`).
        sender_email: String,
        /// Subject line.  Supports `{{field}}` template substitution
        /// from the JSON payload.
        subject_template: String,
        /// Body text.  Defaults to the raw payload when omitted.
        #[serde(default)]
        body_template: Option<String>,
    },
}

/// Determines which user receives a `PersistMessage` step.
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessageTarget {
    /// Literal UUID string configured in the pipeline file.
    UserId { value: String },
    /// Extract the UUID from a field in the JSON payload
    /// (e.g. `field = "user_id"`).
    PayloadField { field: String },
}

#[derive(Debug, thiserror::Error)]
pub enum PipelineConfigError {
    #[error("pipeline config load failed for '{path}': {cause}")]
    Load { path: String, cause: String },
    #[error("pipeline config deserialisation failed for '{path}': {cause}")]
    Deserialise { path: String, cause: String },
}

/// Load a `PipelineConfig` from a concrete NCL file path.
///
/// Returns `Ok(PipelineConfig::default())` when the file does not exist so
/// the server starts cleanly when no pipeline config has been written yet.
pub fn load_pipeline_config(path: &std::path::Path) -> Result<PipelineConfig, PipelineConfigError> {
    let path_str = path.display().to_string();

    if !path.exists() {
        tracing::debug!(path = %path_str, "pipelines: config not found, skipping");
        return Ok(PipelineConfig::default());
    }

    let json =
        rustelo_config::format::load_config(path).map_err(|e| PipelineConfigError::Load {
            path: path_str.clone(),
            cause: e.to_string(),
        })?;

    // Substitute ${VAR} tokens (e.g. ${NATS_NAMESPACE}) in the JSON text before
    // deserialisation — same mechanism used by Config::load().
    let raw = serde_json::to_string(&json).map_err(|e| PipelineConfigError::Load {
        path: path_str.clone(),
        cause: e.to_string(),
    })?;
    let substituted = crate::config::Config::substitute_env_in_string(&raw);

    serde_json::from_str::<PipelineConfig>(&substituted).map_err(|e| {
        PipelineConfigError::Deserialise {
            path: path_str,
            cause: e.to_string(),
        }
    })
}
