//! `TriggerEvent` — the description of the event that fired a script.
//!
//! Built by the dispatcher (in `manager-core`) from the outbox row and
//! attached to the `ExecRequest` that's handed to `executor-core`. The
//! Rhai bridge in `executor-core::engine::build_ctx_map` flattens this
//! into `ctx.event` for the script.
//!
//! Living in `picloud-shared` so the dispatcher and the executor agree
//! on the wire shape. Serializable so cluster mode (v1.3+) can ship
//! ExecRequests over HTTP without rewriting this type.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::{DeadLetterId, ScriptId, TriggerId};

/// Operations a KV trigger can fire on. Stored as a lowercase string
/// in `kv_trigger_details.ops` (Postgres `text[]`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum KvEventOp {
    Insert,
    Update,
    Delete,
}

impl KvEventOp {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Insert => "insert",
            Self::Update => "update",
            Self::Delete => "delete",
        }
    }

    #[must_use]
    pub fn from_wire(s: &str) -> Option<Self> {
        match s {
            "insert" => Some(Self::Insert),
            "update" => Some(Self::Update),
            "delete" => Some(Self::Delete),
            _ => None,
        }
    }
}

/// Operations a docs trigger can fire on. v1.1.2. Stored as a
/// lowercase string in `docs_trigger_details.ops` (Postgres `text[]`).
/// Distinct from `KvEventOp` because docs has CRUD verbs (`create`)
/// instead of KV's set/upsert flavour (`insert`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DocsEventOp {
    Create,
    Update,
    Delete,
}

impl DocsEventOp {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Create => "create",
            Self::Update => "update",
            Self::Delete => "delete",
        }
    }

    #[must_use]
    pub fn from_wire(s: &str) -> Option<Self> {
        match s {
            "create" => Some(Self::Create),
            "update" => Some(Self::Update),
            "delete" => Some(Self::Delete),
            _ => None,
        }
    }
}

/// Operations a files trigger can fire on. v1.1.5. Stored as a
/// lowercase string in `files_trigger_details.ops` (Postgres `text[]`).
/// CRUD verbs (`create`) mirror `DocsEventOp`, distinct from KV's
/// set/upsert flavour.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FilesEventOp {
    Create,
    Update,
    Delete,
}

impl FilesEventOp {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Create => "create",
            Self::Update => "update",
            Self::Delete => "delete",
        }
    }

    #[must_use]
    pub fn from_wire(s: &str) -> Option<Self> {
        match s {
            "create" => Some(Self::Create),
            "update" => Some(Self::Update),
            "delete" => Some(Self::Delete),
            _ => None,
        }
    }
}

/// Discriminated description of a triggering event. Lifted from the
/// outbox row's payload at dispatch time. Each variant carries the
/// fields the corresponding `ctx.event` shape exposes to the script.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "source", rename_all = "snake_case")]
pub enum TriggerEvent {
    /// A KV insert / update / delete fired this handler.
    Kv {
        op: KvEventOp,
        collection: String,
        key: String,
        /// Present on `insert` and `update`. Absent on `delete`.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        value: Option<serde_json::Value>,
    },

    /// A docs create / update / delete fired this handler. v1.1.2.
    /// `data` is the current document state (absent on delete);
    /// `prev_data` is the prior state (absent on create). For update
    /// and delete handlers, `prev_data` is the load-bearing
    /// change-data-capture surface (the repo reads the old row in the
    /// same statement as the write).
    Docs {
        op: DocsEventOp,
        collection: String,
        /// UUID as string — Rhai sees it as a string.
        id: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        data: Option<serde_json::Value>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        prev_data: Option<serde_json::Value>,
    },

    /// A cron schedule fired this handler. v1.1.4. Carries the
    /// schedule + timezone the trigger was configured with, the
    /// canonical cron moment (`scheduled_at`, the instant the
    /// expression *meant*), and when the scheduler actually enqueued
    /// the fire (`fired_at`). Surfaced to scripts as `ctx.event.cron`.
    Cron {
        schedule: String,
        timezone: String,
        scheduled_at: DateTime<Utc>,
        fired_at: DateTime<Utc>,
    },

    /// A files create / update / delete fired this handler. v1.1.5.
    /// Carries the affected file's **metadata only** — never the blob
    /// bytes (files are too big to ship through trigger payloads). A
    /// handler that wants the bytes calls
    /// `files::collection(c).get(id)` itself. `prev` is the prior
    /// metadata for update (and the deleted-row metadata for delete);
    /// absent on create. Surfaced to scripts as `ctx.event.files`.
    Files {
        op: FilesEventOp,
        collection: String,
        /// UUID as string — Rhai sees it as a string.
        id: String,
        name: String,
        content_type: String,
        size: u64,
        /// Lowercase hex SHA-256.
        checksum: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        prev: Option<serde_json::Value>,
    },

    /// A durable pub/sub publish fired this handler. v1.1.5. Carries
    /// the topic, the JSON-decoded message, and the publish instant.
    /// Surfaced to scripts as `ctx.event.pubsub`.
    Pubsub {
        topic: String,
        message: serde_json::Value,
        published_at: DateTime<Utc>,
    },

    /// An inbound email (POSTed to the webhook receiver by a configured
    /// provider) fired this handler. v1.1.7. Carries the normalized
    /// message; `text`/`html` are absent when the provider sent only the
    /// other. Surfaced to scripts as `ctx.event.email`. Attachments are
    /// deferred to v1.2.
    Email {
        from: String,
        to: Vec<String>,
        #[serde(default)]
        cc: Vec<String>,
        subject: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        text: Option<String>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        html: Option<String>,
        received_at: DateTime<Utc>,
        /// RFC 5322 Message-ID, when the provider supplied one.
        #[serde(default, skip_serializing_if = "Option::is_none")]
        message_id: Option<String>,
    },

    /// A queue:receive trigger fired this handler. v1.1.9. Surfaced to
    /// scripts as `ctx.event.queue`. `attempt` is 1-based — it's the
    /// current delivery attempt (increments per nack-and-retry).
    Queue {
        queue_name: String,
        message: serde_json::Value,
        enqueued_at: DateTime<Utc>,
        attempt: u32,
        message_id: String,
    },

    /// A dead-letter row fired this handler. The original event is
    /// nested verbatim plus the dead-letter metadata the design notes
    /// §4 require.
    DeadLetter {
        dead_letter_id: DeadLetterId,
        original: Box<TriggerEvent>,
        attempts: u32,
        last_error: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        trigger_id: Option<TriggerId>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        script_id: Option<ScriptId>,
        first_attempt_at: DateTime<Utc>,
        last_attempt_at: DateTime<Utc>,
    },
}

impl TriggerEvent {
    /// The `source` discriminant the script sees on `ctx.event.source`.
    #[must_use]
    pub const fn source(&self) -> &'static str {
        match self {
            Self::Kv { .. } => "kv",
            Self::Docs { .. } => "docs",
            Self::Cron { .. } => "cron",
            Self::Files { .. } => "files",
            Self::Pubsub { .. } => "pubsub",
            Self::Email { .. } => "email",
            Self::Queue { .. } => "queue",
            Self::DeadLetter { .. } => "dead_letter",
        }
    }
}

/// Convenience accessor on the dead-letter variant for places that
/// already know they're handling a DL event. Pulled out so the
/// dispatcher and the dashboard don't have to repeat the match.
#[derive(Debug, Clone)]
pub struct DeadLetterEventDetail {
    pub dead_letter_id: DeadLetterId,
    pub original: TriggerEvent,
    pub attempts: u32,
    pub last_error: String,
    pub trigger_id: Option<TriggerId>,
    pub script_id: Option<ScriptId>,
    pub first_attempt_at: DateTime<Utc>,
    pub last_attempt_at: DateTime<Utc>,
}

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

    #[test]
    fn queue_event_round_trip_serde() {
        let original = TriggerEvent::Queue {
            queue_name: "payments.process".into(),
            message: serde_json::json!({ "order_id": 123, "amount": 4999 }),
            enqueued_at: DateTime::parse_from_rfc3339("2026-06-06T12:00:00Z")
                .unwrap()
                .with_timezone(&Utc),
            attempt: 2,
            message_id: "01234567-89ab-cdef-0123-456789abcdef".into(),
        };
        let wire = serde_json::to_value(&original).unwrap();
        assert_eq!(wire["source"], "queue");
        assert_eq!(wire["queue_name"], "payments.process");
        assert_eq!(wire["attempt"], 2);
        let back: TriggerEvent = serde_json::from_value(wire).unwrap();
        match back {
            TriggerEvent::Queue {
                queue_name,
                attempt,
                ..
            } => {
                assert_eq!(queue_name, "payments.process");
                assert_eq!(attempt, 2);
            }
            other => panic!("expected Queue, got {other:?}"),
        }
    }

    #[test]
    fn queue_source_discriminant() {
        let event = TriggerEvent::Queue {
            queue_name: "x".into(),
            message: serde_json::Value::Null,
            enqueued_at: Utc::now(),
            attempt: 1,
            message_id: "id".into(),
        };
        assert_eq!(event.source(), "queue");
    }
}
