//! `ServiceEventEmitter` — the contract every stateful SDK service uses
//! to publish events into the (future) triggers framework.
//!
//! v1.1.0 ships only the trait shape and a `NoopEventEmitter` that
//! drops every event. The real outbox-backed implementation lands with
//! the triggers PR in v1.1.1; locking the trait now means services
//! written in subsequent v1.1.x PRs (KV, docs, files, …) don't have to
//! re-thread their plumbing when the dispatcher arrives.
//!
//! Design rationale (full discussion: `docs/sdk-shape.md`):
//!   * Async — outbox writes hit Postgres.
//!   * Cx is passed in so the emitter can attribute the event to the
//!     `app_id` / `principal` / `execution_id` that produced it.
//!   * Events carry their semantic identity (`source` + `op`) plus
//!     optional locator (`collection` + `key`) and optional payloads
//!     (`payload` for the new value, `old_payload` for the previous on
//!     updates). The dispatcher matches on (source, op, collection)
//!     filters to decide which scripts to fan out to.

use async_trait::async_trait;
use thiserror::Error;

use crate::{GroupId, SdkCallCx};

/// Trait every stateful service depends on to emit events. The host
/// binary constructs one instance and clones the Arc into each service.
#[async_trait]
pub trait ServiceEventEmitter: Send + Sync {
    /// Publish a single event. Implementations are expected to be
    /// fire-and-forget from the caller's perspective: the outbox impl
    /// will return `Ok(())` once the event is durably persisted, the
    /// dispatcher reads it out-of-band.
    async fn emit(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError>;

    /// §11.6: publish an event for a write to a SHARED (group-owned)
    /// collection. Unlike `emit` (which fans out over the writer app's own +
    /// inherited triggers), this matches `shared = true` triggers on the
    /// `owning_group` and runs the handler under the writer app (`cx.app_id`).
    /// Default no-op so the noop emitter + any non-outbox impls drop it.
    async fn emit_shared(
        &self,
        cx: &SdkCallCx,
        owning_group: GroupId,
        event: ServiceEvent,
    ) -> Result<(), EmitError> {
        let _ = (cx, owning_group, event);
        Ok(())
    }
}

/// One service event. `source` and `op` are `&'static str` because they
/// come from a fixed enumeration baked into each service (`"kv"` +
/// `"insert"`/`"update"`/`"delete"`, etc.) — never from user data.
/// `collection`/`key`/payloads come from user data and are owned.
#[derive(Debug, Clone)]
pub struct ServiceEvent {
    /// Service namespace. Matches the Rhai module name: `"kv"`,
    /// `"docs"`, `"files"`, etc.
    pub source: &'static str,

    /// Operation verb. Each service defines its own vocabulary;
    /// dispatcher filters match on the literal string.
    pub op: &'static str,

    /// Affected collection, when the service is collection-scoped
    /// (`kv`, `docs`, `files`). `None` for collection-less events.
    pub collection: Option<String>,

    /// Affected key/id within the collection, when applicable.
    pub key: Option<String>,

    /// New value after the operation, when carrying it is cheap and
    /// useful. `None` for deletes.
    pub payload: Option<serde_json::Value>,

    /// Previous value before the operation, populated on `update` /
    /// `delete` so triggers can diff. `None` on `insert`.
    pub old_payload: Option<serde_json::Value>,
}

/// Errors an emitter can surface upward. The noop impl never returns
/// these; the v1.1.1 outbox impl uses `Unavailable` for pool/connection
/// failures and `Rejected` for malformed payloads (e.g. event JSON too
/// large for the outbox row).
#[derive(Debug, Error)]
pub enum EmitError {
    #[error("event sink unavailable: {0}")]
    Unavailable(String),
    #[error("event sink rejected event: {0}")]
    Rejected(String),
}

/// Default emitter for v1.1.0. Accepts every event, persists nothing,
/// always returns `Ok(())`. Wired in the picloud binary; the v1.1.1
/// triggers PR swaps this for a Postgres outbox writer.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopEventEmitter;

#[async_trait]
impl ServiceEventEmitter for NoopEventEmitter {
    async fn emit(&self, _cx: &SdkCallCx, _event: ServiceEvent) -> Result<(), EmitError> {
        Ok(())
    }
}

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

    // Compile-time check that ServiceEventEmitter is dyn-safe — every
    // service holds it as `Arc<dyn ServiceEventEmitter>` and would
    // silently break the workspace if a non-object-safe method snuck
    // in. Behavioural tests for the noop impl come for free once a
    // service exercises it (v1.1.1+); avoid pulling tokio into
    // `picloud-shared` just for a one-line `emit().await` check.
    #[allow(dead_code)]
    fn assert_dyn_compatible(_e: &dyn ServiceEventEmitter) {}

    #[test]
    fn service_event_construction_is_explicit() {
        // Pin the field layout so a re-ordering in a future PR causes a
        // compile failure here rather than silently misattributing
        // events. Hash-derive isn't appropriate (serde_json::Value isn't
        // Hash), so structural construction is the assertion.
        let _ = ServiceEvent {
            source: "kv",
            op: "insert",
            collection: Some("widgets".into()),
            key: Some("k1".into()),
            payload: Some(serde_json::json!({"v": 1})),
            old_payload: None,
        };
    }
}
