//! Pipeline step executor.
//!
//! Takes a [`Pipeline`] and its incoming NATS payload, then runs each step in
//! order.  Step failures are logged but do **not** abort subsequent steps —
//! the pipeline always runs to completion.

use std::sync::Arc;

use rustelo_core_lib::cache::content::ContentCache;
#[cfg(not(feature = "auth"))]
use tracing::warn;
use tracing::{error, info};

use super::pipeline::{MessageTarget, Pipeline, PipelineStep};
use crate::content::websocket::{ContentNotification, WebSocketServer};

/// Shared server resources passed to every step executor.
#[derive(Clone)]
pub struct PipelineContext {
    pub ws: Arc<WebSocketServer>,
    pub cache: Arc<ContentCache>,
    #[cfg(feature = "auth")]
    pub auth_repo: Arc<crate::database::auth::AuthRepository>,
    /// Absolute directory from which relative script paths are resolved
    /// (typically `$SITE_CONTENT_PATH`).
    pub scripts_root: String,
}

/// Execute all steps for `pipeline` with the given `event_id` and raw
/// `payload`.
///
/// `event_id` is the trigger identifier: the actual NATS subject received for
/// NATS pipelines, or the CI/CD event name for CI/CD pipelines.
///
/// Step errors are logged; execution continues with the next step.
pub async fn execute_pipeline(
    pipeline: &Pipeline,
    event_id: &str,
    payload: &[u8],
    ctx: &PipelineContext,
) {
    info!(pipeline = %pipeline.name, event = %event_id, "pipeline: triggered");

    for (i, step) in pipeline.steps.iter().enumerate() {
        if let Err(e) = execute_step(step, event_id, payload, ctx).await {
            error!(
                pipeline = %pipeline.name,
                step     = i,
                error    = %e,
                "pipeline: step failed, continuing"
            );
        }
    }
}

async fn execute_step(
    step: &PipelineStep,
    subject: &str,
    payload: &[u8],
    ctx: &PipelineContext,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    match step {
        // ── Cache invalidation ────────────────────────────────────────────────
        PipelineStep::CacheInvalidate => {
            ctx.cache.clear_all();
            info!(event = %subject, "pipeline: content cache flushed");
            Ok(())
        }

        // ── WebSocket broadcast ───────────────────────────────────────────────
        PipelineStep::Broadcast { kind, message } => {
            ctx.ws
                .broadcast_notification(ContentNotification::ToastBroadcast {
                    kind: kind.clone(),
                    message: message.clone(),
                })
                .await;
            info!(event = %subject, kind = %kind, "pipeline: toast broadcast sent");
            Ok(())
        }

        // ── External script ───────────────────────────────────────────────────
        PipelineStep::Script { path } => {
            let resolved = resolve_script_path(path, &ctx.scripts_root);
            let payload_str = String::from_utf8_lossy(payload);

            let (program, args): (&str, Vec<&str>) = if resolved.ends_with(".nu") {
                ("nu", vec![&resolved])
            } else if resolved.ends_with(".sh") {
                ("sh", vec![&resolved])
            } else {
                // Treat as a directly-executable binary / script with shebang.
                (resolved.as_str(), vec![])
            };

            let status = tokio::process::Command::new(program)
                .args(&args)
                // Generic env vars usable by any pipeline type.
                .env("PIPELINE_EVENT", subject)
                .env("PIPELINE_PAYLOAD", payload_str.as_ref())
                // Legacy aliases kept for backwards compatibility with NATS scripts.
                .env("NATS_SUBJECT", subject)
                .env("NATS_PAYLOAD", payload_str.as_ref())
                .stdin(std::process::Stdio::null())
                .stdout(std::process::Stdio::inherit())
                .stderr(std::process::Stdio::inherit())
                .status()
                .await
                .map_err(|e| format!("failed to spawn '{}': {}", resolved, e))?;

            if status.success() {
                info!(script = %resolved, event = %subject, "pipeline: script completed");
                Ok(())
            } else {
                Err(format!("script '{}' exited with {}", resolved, status).into())
            }
        }

        // ── Persist to user inbox ─────────────────────────────────────────────
        #[cfg(feature = "auth")]
        PipelineStep::PersistMessage {
            target,
            sender_email,
            subject_template,
            body_template,
        } => {
            let payload_str = String::from_utf8_lossy(payload);
            let payload_json: serde_json::Value =
                serde_json::from_slice(payload).unwrap_or(serde_json::Value::Null);

            let user_id = resolve_target(target, &payload_json)?;
            let msg_subject = render_template(subject_template, &payload_json);
            let msg_body = body_template
                .as_deref()
                .map(|t| render_template(t, &payload_json))
                .unwrap_or_else(|| payload_str.into_owned());

            ctx.auth_repo
                .insert_message(user_id, sender_email, &msg_subject, &msg_body)
                .await
                .map_err(|e| format!("insert_message failed: {e}"))?;

            info!(
                event   = %subject,
                user_id = %user_id,
                "pipeline: message persisted to user inbox"
            );
            Ok(())
        }

        // When auth feature is disabled, PersistMessage is a no-op with a warning.
        #[cfg(not(feature = "auth"))]
        PipelineStep::PersistMessage { .. } => {
            warn!(
                event = %subject,
                "pipeline: PersistMessage step requires the 'auth' feature — skipping"
            );
            Ok(())
        }
    }
}

/// Resolve `path` against `scripts_root` unless it's already absolute.
fn resolve_script_path(path: &str, scripts_root: &str) -> String {
    if std::path::Path::new(path).is_absolute() {
        path.to_owned()
    } else {
        format!("{}/{}", scripts_root.trim_end_matches('/'), path)
    }
}

/// Extract the target user UUID from config or JSON payload.
#[cfg(feature = "auth")]
fn resolve_target(
    target: &MessageTarget,
    payload: &serde_json::Value,
) -> Result<uuid::Uuid, Box<dyn std::error::Error + Send + Sync>> {
    let raw = match target {
        MessageTarget::UserId { value } => value.clone(),
        MessageTarget::PayloadField { field } => payload
            .get(field)
            .and_then(|v| v.as_str())
            .ok_or_else(|| format!("payload missing field '{field}'"))?
            .to_owned(),
    };
    uuid::Uuid::parse_str(&raw).map_err(|e| format!("invalid user UUID '{raw}': {e}").into())
}

/// Minimal `{{field}}` template rendering from a flat JSON object.
///
/// Only top-level string fields are substituted.  Unresolved placeholders are
/// left as-is.
fn render_template(template: &str, payload: &serde_json::Value) -> String {
    let Some(obj) = payload.as_object() else {
        return template.to_owned();
    };

    let mut out = template.to_owned();
    for (key, val) in obj {
        if let Some(s) = val.as_str() {
            out = out.replace(&format!("{{{{{key}}}}}"), s);
        }
    }
    out
}
