//! CloudEvents 1.0 projection layer for tsafe.
//!
//! Converts internal audit entries and vault operations into spec-compliant
//! `application/cloudevents+json` envelopes.  The vault file remains the
//! authoritative source of truth; events are projections only.
//!
//! # Security rule
//! Secret *values* are never included in events.  When a key name appears it
//! is replaced with an opaque `key_ref` (SHA-256 of `profile:key`) so that
//! event payloads can safely traverse external sinks (webhooks, NATS, log
//! aggregators) without leaking metadata about what secrets a vault contains.

use std::io::Write as _;
#[cfg(feature = "nats")]
use std::time::Duration;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;

use crate::audit::{AuditEntry, AuditExecContext, AuditStatus};
use crate::lifecycle::classify_operation;

// ── key_ref ──────────────────────────────────────────────────────────────────

/// Return a deterministic, opaque reference for a secret key.
///
/// `key_ref = hex(SHA-256(profile ":" key))`
///
/// The profile is mixed in so the same key name in two different vaults
/// produces different refs, preventing cross-vault correlation.
pub fn key_ref(profile: &str, key: &str) -> String {
    let mut h = Sha256::new();
    h.update(profile.as_bytes());
    h.update(b":");
    h.update(key.as_bytes());
    format!("{:x}", h.finalize())
}

// ── CloudEvent envelope ───────────────────────────────────────────────────────

/// Minimal CloudEvents 1.0 envelope.
///
/// Serialised as `application/cloudevents+json` (one envelope per line in JSONL
/// mode).  Only the mandatory attributes plus `datacontenttype` are included;
/// extension attributes are not needed for Phase 1.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CloudEvent {
    /// Always `"1.0"`.
    pub specversion: String,
    /// UUIDv4 — unique per event occurrence.
    pub id: String,
    /// `tsafe/<profile>` — identifies the vault that produced this event.
    pub source: String,
    /// Reverse-DNS type identifier, e.g. `com.tsafe.vault.secret.set.v1`.
    #[serde(rename = "type")]
    pub event_type: String,
    /// ISO-8601 UTC timestamp of the event.
    pub time: DateTime<Utc>,
    /// Always `"application/json"`.
    pub datacontenttype: String,
    /// Event-specific payload.  Never contains plaintext secret values.
    pub data: serde_json::Value,
}

impl CloudEvent {
    /// Construct a new envelope with a fresh `id` and `time = now`.
    pub fn new(source: &str, event_type: impl Into<String>, data: serde_json::Value) -> Self {
        Self {
            specversion: "1.0".to_string(),
            id: Uuid::new_v4().to_string(),
            source: source.to_string(),
            event_type: event_type.into(),
            time: Utc::now(),
            datacontenttype: "application/json".to_string(),
            data,
        }
    }

    /// Project an [`AuditEntry`] into a CloudEvent.
    ///
    /// The `key` field from the audit entry is replaced with an opaque
    /// `key_ref` (see [`key_ref`]).  The original audit timestamp is
    /// preserved as the event `time` so replay is accurate.
    pub fn from_audit(entry: &AuditEntry) -> Self {
        let source = format!("tsafe/{}", entry.profile);
        let classification = classify_operation(&entry.operation);
        let event_type = audit_op_to_ce_type(&entry.operation);
        let computed_key_ref = entry.key.as_deref().map(|k| key_ref(&entry.profile, k));
        let mut data = serde_json::Map::new();
        data.insert("audit_id".into(), serde_json::json!(entry.id));
        data.insert("operation".into(), serde_json::json!(entry.operation));
        data.insert("key_ref".into(), serde_json::json!(computed_key_ref));
        data.insert(
            "status".into(),
            serde_json::json!(if entry.status == AuditStatus::Success {
                "success"
            } else {
                "failure"
            }),
        );
        data.insert("message".into(), serde_json::json!(entry.message));
        if let Some(state) = classification.lifecycle_state {
            data.insert(
                "lifecycle".into(),
                serde_json::to_value(state).unwrap_or(serde_json::Value::Null),
            );
        }
        if let Some(exec) = entry
            .context
            .as_ref()
            .and_then(|context| context.exec.as_ref())
        {
            data.insert(
                "authority".into(),
                serde_json::json!({
                    "exec": project_exec_context(&entry.profile, exec),
                }),
            );
        }

        let mut ce = Self::new(&source, event_type, serde_json::Value::Object(data));
        ce.time = entry.timestamp; // use original audit timestamp, not now()
        ce
    }
}

fn project_exec_context(profile: &str, exec: &AuditExecContext) -> serde_json::Value {
    serde_json::json!({
        "contract_name": exec.contract_name,
        "target": exec.target,
        "target_decision": exec.target_decision,
        "matched_target": exec.matched_target,
        "authority_profile": exec.authority_profile,
        "authority_namespace": exec.authority_namespace,
        "trust_level": exec.trust_level.map(|value| value.as_str()),
        "access_profile": exec.access_profile.map(|value| value.as_str()),
        "inherit": exec.inherit.map(|value| value.as_str()),
        "deny_dangerous_env": exec.deny_dangerous_env,
        "redact_output": exec.redact_output,
        "network": exec.network.map(|value| value.as_str()),
        "allowed_secret_refs": hash_names(profile, &exec.allowed_secrets),
        "required_secret_refs": hash_names(profile, &exec.required_secrets),
        "injected_secret_refs": hash_names(profile, &exec.injected_secrets),
        "missing_required_secret_refs": hash_names(profile, &exec.missing_required_secrets),
        "dropped_env_names": exec.dropped_env_names,
        "target_allowed": exec.target_allowed,
    })
}

fn hash_names(profile: &str, names: &[String]) -> Vec<String> {
    let mut out = names
        .iter()
        .map(|name| name.trim())
        .filter(|name| !name.is_empty())
        .map(|name| key_ref(profile, name))
        .collect::<Vec<_>>();
    out.sort();
    out.dedup();
    out
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct NatsAdapterConfig {
    url: String,
    subject: String,
}

fn nats_adapter_from_env() -> Option<NatsAdapterConfig> {
    let url = std::env::var("TSAFE_EVENTS_NATS_URL").ok()?;
    let subject = std::env::var("TSAFE_EVENTS_NATS_SUBJECT").ok()?;
    let url = url.trim();
    let subject = subject.trim();
    if url.is_empty() || subject.is_empty() {
        return None;
    }
    Some(NatsAdapterConfig {
        url: url.to_string(),
        subject: subject.to_string(),
    })
}

#[cfg(any(test, feature = "nats"))]
fn publish_nats_with<F>(cfg: &NatsAdapterConfig, line: &str, publish: F) -> Result<(), String>
where
    F: FnOnce(&str, &str, &[u8]) -> Result<(), String>,
{
    publish(&cfg.url, &cfg.subject, line.as_bytes())
}

#[cfg(feature = "nats")]
fn publish_nats(cfg: &NatsAdapterConfig, line: &str) -> Result<(), String> {
    publish_nats_with(cfg, line, |url, subject, payload| {
        let connection = nats::Options::new()
            .with_name("tsafe-events")
            .connect(url)
            .map_err(|error| format!("connect failed: {error}"))?;
        connection
            .publish(subject, payload)
            .map_err(|error| format!("publish failed: {error}"))?;
        connection
            .flush_timeout(Duration::from_secs(2))
            .map_err(|error| format!("flush failed: {error}"))?;
        Ok(())
    })
}

#[cfg(not(feature = "nats"))]
fn warn_nats_feature_disabled() {
    static WARN_ONCE: std::sync::Once = std::sync::Once::new();
    WARN_ONCE.call_once(|| {
        tracing::warn!(
            "TSAFE_EVENTS_NATS_URL/TSAFE_EVENTS_NATS_SUBJECT were set, but tsafe-core was built without the `nats` feature"
        );
    });
}

// ── operation → CloudEvents type mapping ─────────────────────────────────────

/// Map a vault operation name (as recorded in the audit log) to a CloudEvents
/// `type` string following the `com.tsafe.*.v1` taxonomy.
pub fn audit_op_to_ce_type(operation: &str) -> String {
    if let Some(event_type) = classify_operation(operation).event_type {
        event_type.to_string()
    } else {
        format!("com.tsafe.{operation}.v1")
    }
}

// ── event adapters ────────────────────────────────────────────────────────────

/// Emit a [`CloudEvent`] through all configured adapters.
///
/// # Durability contract (ADR-023)
///
/// All three adapters are **at-most-once delivery, fire-and-forget**.
/// A failure in any adapter is silently logged (warning only) so vault
/// operations are never blocked. No adapter retries on failure. No adapter
/// provides acknowledgement or deduplication. Event loss is accepted for fast
/// CLI commands — network partitions, crashes, and process exits will cause
/// loss without alerting the operator.
///
/// **The per-profile `audit.jsonl` file is the authoritative record.**
/// Events are a secondary projection channel. Operators who need guaranteed
/// delivery must consume the audit log directly or implement their own
/// reconciliation layer.
///
/// # No-plaintext-secret invariant
///
/// Event payloads must never contain plaintext secret values or plaintext key
/// names. This invariant is enforced at the [`CloudEvent::from_audit`]
/// projection layer before any adapter serialises the payload. Secret names
/// are replaced with an opaque `key_ref = hex(SHA-256(profile:key))` hash.
///
/// | Env var | Behaviour |
/// |---------|-----------|
/// | `TSAFE_EVENTS_OUTBOX=<path>` | Append one JSONL line to the file |
/// | `TSAFE_EVENTS_OUTBOX=-` | Write one JSONL line to stderr |
/// | `TSAFE_EVENTS_WEBHOOK_URL=https://…` | HTTP POST in a background thread |
/// | `TSAFE_EVENTS_NATS_URL=nats://…` + `TSAFE_EVENTS_NATS_SUBJECT=…` | Publish to NATS when built with the `nats` feature |
///
/// If no env vars are set this function is a noop.
pub fn emit(event: &CloudEvent) {
    let line = match serde_json::to_string(event) {
        Ok(s) => s,
        Err(_) => return,
    };

    // ── outbox adapter ──────────────────────────────────────────────────────
    // At-most-once delivery. Fire-and-forget. Audit log is authoritative.
    // Event loss is accepted on fast CLI commands.
    if let Ok(outbox) = std::env::var("TSAFE_EVENTS_OUTBOX") {
        if !outbox.is_empty() {
            if outbox == "-" || outbox.eq_ignore_ascii_case("stderr") {
                // Stderr adapter — safe to mix with stdout commands.
                let _ = writeln!(std::io::stderr(), "{line}");
            } else {
                // File adapter — JSONL append.
                let _ = (|| -> std::io::Result<()> {
                    let mut f = std::fs::OpenOptions::new()
                        .create(true)
                        .append(true)
                        .open(&outbox)?;
                    writeln!(f, "{line}")
                })();
            }
        }
    }

    // ── HTTP webhook adapter (fire-and-forget) ──────────────────────────────
    // At-most-once delivery. Fire-and-forget. Audit log is authoritative.
    // Event loss is accepted on fast CLI commands. HTTPS only — never sends
    // events over plain HTTP.
    if let Ok(url) = std::env::var("TSAFE_EVENTS_WEBHOOK_URL") {
        if url.starts_with("https://") {
            let line_cloned = line.clone();
            let url_cloned = url.clone();
            std::thread::spawn(move || {
                if let Err(e) = ureq::post(&url_cloned)
                    .set("Content-Type", "application/cloudevents+json")
                    .send_string(&line_cloned)
                {
                    tracing::warn!(url = %url_cloned, error = %e, "events webhook POST failed");
                }
            });
        }
    }

    // ── NATS adapter (fire-and-forget, feature-gated) ──────────────────────
    // At-most-once delivery. Fire-and-forget. Audit log is authoritative.
    // Event loss is accepted on fast CLI commands.
    if let Some(cfg) = nats_adapter_from_env() {
        #[cfg(feature = "nats")]
        {
            let line_cloned = line.clone();
            std::thread::spawn(move || {
                if let Err(error) = publish_nats(&cfg, &line_cloned) {
                    tracing::warn!(
                        url = %cfg.url,
                        subject = %cfg.subject,
                        error = %error,
                        "events NATS publish failed"
                    );
                }
            });
        }

        #[cfg(not(feature = "nats"))]
        {
            let _ = cfg;
            warn_nats_feature_disabled();
        }
    }
}

/// Convenience wrapper: build a [`CloudEvent`] from raw audit parts and emit it.
///
/// Constructs an [`AuditEntry`] with `now` as timestamp, projects it to a
/// `CloudEvent`, and calls [`emit`].  This is the one-liner callers use at
/// workflow boundaries.
///
/// # Span instrumentation (ADR-024)
///
/// The span records `profile` and `operation` only.  The `key` parameter is
/// explicitly skipped — it contains the plaintext secret-key name, which must
/// never appear in span attributes, log fields, or OTel exports.  The emitted
/// event payload already replaces the key name with an opaque `key_ref`
/// (SHA-256) before any adapter serialises it.
#[tracing::instrument(skip(key), fields(profile = %profile, operation = %operation))]
pub fn emit_event(profile: &str, operation: &str, key: Option<&str>) {
    let entry = AuditEntry::success(profile, operation, key);
    emit(&CloudEvent::from_audit(&entry));
}

// ── tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::audit::{AuditContext, AuditEntry, AuditExecContext};
    use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};

    #[test]
    fn key_ref_is_deterministic_and_opaque() {
        let r1 = key_ref("dev", "DB_PASSWORD");
        let r2 = key_ref("dev", "DB_PASSWORD");
        assert_eq!(r1, r2, "same inputs must produce same ref");
        assert!(
            !r1.contains("DB_PASSWORD"),
            "key name must not appear in ref"
        );
        assert_eq!(r1.len(), 64, "SHA-256 hex is 64 chars");
    }

    #[test]
    fn key_ref_differs_across_profiles() {
        let r_dev = key_ref("dev", "API_KEY");
        let r_prod = key_ref("prod", "API_KEY");
        assert_ne!(r_dev, r_prod, "same key in different profiles must differ");
    }

    #[test]
    fn from_audit_happy_path() {
        let entry = AuditEntry::success("main", "set", Some("MY_KEY"));
        let ce = CloudEvent::from_audit(&entry);

        assert_eq!(ce.specversion, "1.0");
        assert_eq!(ce.source, "tsafe/main");
        assert_eq!(ce.event_type, "com.tsafe.vault.secret.set.v1");
        assert_eq!(ce.datacontenttype, "application/json");
        assert_eq!(ce.time, entry.timestamp);

        let data = &ce.data;
        assert_eq!(data["audit_id"], entry.id);
        assert_eq!(data["operation"], "set");
        assert_eq!(data["status"], "success");

        // key_ref must be present and opaque
        let kr = data["key_ref"].as_str().unwrap();
        assert_eq!(kr.len(), 64);
        assert!(!kr.contains("MY_KEY"));
    }

    #[test]
    fn from_audit_projects_lifecycle_state_for_vault_and_share_ops() {
        let created = CloudEvent::from_audit(&AuditEntry::success("main", "init", None));
        assert_eq!(created.data["lifecycle"]["domain"], "vault");
        assert_eq!(created.data["lifecycle"]["state"], "created");

        let shared = CloudEvent::from_audit(&AuditEntry::success("main", "share-once", None));
        assert_eq!(shared.data["lifecycle"]["domain"], "share");
        assert_eq!(shared.data["lifecycle"]["state"], "published");
    }

    #[test]
    fn from_audit_projects_lifecycle_state_for_secret_ops() {
        let written = CloudEvent::from_audit(&AuditEntry::success("main", "set", Some("K")));
        assert_eq!(written.data["lifecycle"]["domain"], "secret");
        assert_eq!(written.data["lifecycle"]["state"], "written");

        let accessed = CloudEvent::from_audit(&AuditEntry::success("main", "get", Some("K")));
        assert_eq!(accessed.data["lifecycle"]["domain"], "secret");
        assert_eq!(accessed.data["lifecycle"]["state"], "accessed");

        let deleted = CloudEvent::from_audit(&AuditEntry::success("main", "delete", Some("K")));
        assert_eq!(deleted.data["lifecycle"]["domain"], "secret");
        assert_eq!(deleted.data["lifecycle"]["state"], "deleted");

        let imported = CloudEvent::from_audit(&AuditEntry::success("main", "import", None));
        assert_eq!(imported.data["lifecycle"]["domain"], "secret");
        assert_eq!(imported.data["lifecycle"]["state"], "imported");

        let exported = CloudEvent::from_audit(&AuditEntry::success("main", "export", None));
        assert_eq!(exported.data["lifecycle"]["domain"], "secret");
        assert_eq!(exported.data["lifecycle"]["state"], "exported");

        let namespace_copy = CloudEvent::from_audit(&AuditEntry::success("main", "ns-copy", None));
        assert_eq!(namespace_copy.data["lifecycle"]["domain"], "secret");
        assert_eq!(namespace_copy.data["lifecycle"]["state"], "written");
    }

    #[test]
    fn from_audit_projects_lifecycle_state_for_surface_aliases() {
        let created = CloudEvent::from_audit(&AuditEntry::success("main", "create", None));
        assert_eq!(created.data["lifecycle"]["domain"], "vault");
        assert_eq!(created.data["lifecycle"]["state"], "created");
        assert_eq!(created.event_type, "com.tsafe.vault.created.v1");

        let team_created = CloudEvent::from_audit(&AuditEntry::success("main", "team-init", None));
        assert_eq!(team_created.data["lifecycle"]["domain"], "vault");
        assert_eq!(team_created.data["lifecycle"]["state"], "created");
        assert_eq!(team_created.event_type, "com.tsafe.vault.created.v1");

        let namespace_move = CloudEvent::from_audit(&AuditEntry::success("main", "ns-move", None));
        assert_eq!(namespace_move.data["lifecycle"]["domain"], "vault");
        assert_eq!(namespace_move.data["lifecycle"]["state"], "secret_moved");
        assert_eq!(namespace_move.event_type, "com.tsafe.ns-move.v1");
    }

    #[test]
    fn from_audit_projects_lifecycle_state_for_policy_and_helper_ops() {
        let policy_set = CloudEvent::from_audit(&AuditEntry::success("main", "policy-set", None));
        assert_eq!(policy_set.data["lifecycle"]["domain"], "policy");
        assert_eq!(policy_set.data["lifecycle"]["state"], "set");
        assert_eq!(policy_set.event_type, "com.tsafe.policy-set.v1");

        let rotate_due = CloudEvent::from_audit(&AuditEntry::success("main", "rotate-due", None));
        assert_eq!(rotate_due.data["lifecycle"]["domain"], "policy");
        assert_eq!(rotate_due.data["lifecycle"]["state"], "due_checked");
        assert_eq!(rotate_due.event_type, "com.tsafe.secret.rotation_due.v1");

        let helper_get =
            CloudEvent::from_audit(&AuditEntry::success("main", "credential-helper-get", None));
        assert_eq!(helper_get.data["lifecycle"]["domain"], "credential_helper");
        assert_eq!(helper_get.data["lifecycle"]["state"], "accessed");
        assert_eq!(helper_get.event_type, "com.tsafe.credential-helper-get.v1");

        let helper_erase = CloudEvent::from_audit(&AuditEntry::success(
            "main",
            "credential-helper-erase",
            None,
        ));
        assert_eq!(
            helper_erase.data["lifecycle"]["domain"],
            "credential_helper"
        );
        assert_eq!(helper_erase.data["lifecycle"]["state"], "erased");
        assert_eq!(
            helper_erase.event_type,
            "com.tsafe.credential-helper-erase.v1"
        );
    }

    #[test]
    fn from_audit_projects_lifecycle_state_for_team_membership_ops() {
        let added = CloudEvent::from_audit(&AuditEntry::success("main", "team-add-member", None));
        assert_eq!(added.data["lifecycle"]["domain"], "team");
        assert_eq!(added.data["lifecycle"]["state"], "member_added");
        assert_eq!(added.event_type, "com.tsafe.team-add-member.v1");

        let removed =
            CloudEvent::from_audit(&AuditEntry::success("main", "team-remove-member", None));
        assert_eq!(removed.data["lifecycle"]["domain"], "team");
        assert_eq!(removed.data["lifecycle"]["state"], "member_removed");
        assert_eq!(removed.event_type, "com.tsafe.team-remove-member.v1");
    }

    #[test]
    fn from_audit_projects_lifecycle_state_for_session_and_sync_ops() {
        let unlocked = CloudEvent::from_audit(&AuditEntry::success("main", "unlock", None));
        assert_eq!(unlocked.data["lifecycle"]["domain"], "session");
        assert_eq!(unlocked.data["lifecycle"]["state"], "unlocked");

        let pulled = CloudEvent::from_audit(&AuditEntry::success("main", "kv-pull", None));
        assert_eq!(pulled.data["lifecycle"]["domain"], "sync");
        assert_eq!(pulled.data["lifecycle"]["state"], "pull_completed");

        let merged = CloudEvent::from_audit(&AuditEntry::success("main", "sync", None));
        assert_eq!(merged.data["lifecycle"]["domain"], "sync");
        assert_eq!(merged.data["lifecycle"]["state"], "merged");
    }

    #[test]
    fn from_audit_no_key() {
        let entry = AuditEntry::success("main", "export", None);
        let ce = CloudEvent::from_audit(&entry);
        assert_eq!(ce.data["key_ref"], serde_json::Value::Null);
    }

    #[test]
    fn from_audit_projects_exec_authority_without_plaintext_secret_names() {
        let contract = AuthorityContract {
            name: "deploy".into(),
            profile: Some("work".into()),
            namespace: Some("infra".into()),
            access_profile: crate::rbac::RbacProfile::ReadOnly,
            allow_all_secrets: false,
            allowed_secrets: vec!["API_KEY".into(), "DB_PASSWORD".into()],
            required_secrets: vec!["DB_PASSWORD".into()],
            allowed_targets: vec!["terraform".into()],
            trust: AuthorityTrust::Hardened,
            network: AuthorityNetworkPolicy::Restricted,
        };
        let entry = AuditEntry::success("dev", "exec", None).with_context(AuditContext::from_exec(
            AuditExecContext::from_contract(&contract)
                .with_target("/usr/bin/terraform")
                .with_injected_secrets(["DB_PASSWORD"])
                .with_missing_required_secrets(["API_KEY"])
                .with_dropped_env_names(["OPENAI_API_KEY"])
                .with_target_evaluation(&contract.evaluate_target(Some("/usr/bin/terraform"))),
        ));

        let ce = CloudEvent::from_audit(&entry);
        let exec = &ce.data["authority"]["exec"];
        assert_eq!(exec["contract_name"], "deploy");
        assert_eq!(exec["target_decision"], "allowed_basename");
        assert_eq!(exec["matched_target"], "terraform");
        assert_eq!(exec["trust_level"], "hardened");
        assert_eq!(exec["access_profile"], "read_only");
        assert_eq!(exec["inherit"], "minimal");
        assert_eq!(exec["network"], "restricted");
        assert_eq!(
            exec["dropped_env_names"],
            serde_json::json!(["OPENAI_API_KEY"])
        );
        assert_eq!(exec["allowed_secret_refs"].as_array().unwrap().len(), 2);
        let encoded = serde_json::to_string(&ce).unwrap();
        assert!(!encoded.contains("DB_PASSWORD"));
        assert!(!encoded.contains(r#""API_KEY""#));
    }

    #[test]
    fn audit_op_mapping_coverage() {
        // All well-known operations must produce a com.tsafe. prefix.
        for op in &[
            "set",
            "delete",
            "get",
            "init",
            "rotate",
            "export",
            "import",
            "exec",
            "create",
            "team-init",
            "team-add-member",
            "team-remove-member",
            "policy-set",
            "policy-remove",
            "unlock",
            "kv-pull",
            "vault-pull",
            "op-pull",
            "pull",
            "ns-copy",
            "ns-move",
            "credential-helper-get",
            "credential-helper-store",
            "credential-helper-erase",
            "share-once",
            "receive-once",
            "snap",
            "snap-receive",
            "rotate-due",
        ] {
            let t = audit_op_to_ce_type(op);
            assert!(t.starts_with("com.tsafe."), "bad type for op '{op}': {t}");
            assert!(
                t.ends_with(".v1"),
                "type must end with .v1 for op '{op}': {t}"
            );
        }
    }

    #[test]
    fn unknown_op_gets_fallback_type() {
        let t = audit_op_to_ce_type("custom-op");
        assert_eq!(t, "com.tsafe.custom-op.v1");
    }

    #[test]
    fn snapshot_restore_keeps_fallback_event_type() {
        let t = audit_op_to_ce_type("snapshot-restore");
        assert_eq!(t, "com.tsafe.snapshot-restore.v1");
    }

    #[test]
    fn cloud_event_serialises_correctly() {
        let entry = AuditEntry::success("dev", "set", Some("API_KEY"));
        let ce = CloudEvent::from_audit(&entry);
        let json = serde_json::to_string(&ce).unwrap();
        assert!(json.contains(r#""specversion":"1.0""#));
        assert!(json.contains(r#""type":"com.tsafe.vault.secret.set.v1""#));
        assert!(json.contains(r#""datacontenttype":"application/json""#));
        // Secret key name must not appear anywhere in the serialised event.
        assert!(
            !json.contains("API_KEY"),
            "plaintext key name must not appear in event JSON"
        );
    }

    #[test]
    fn emit_to_file_outbox() {
        use tempfile::tempdir;
        let dir = tempdir().unwrap();
        let outbox = dir.path().join("events.jsonl");

        temp_env::with_var("TSAFE_EVENTS_OUTBOX", outbox.to_str(), || {
            emit_event("dev", "set", Some("SECRET_KEY"));
            emit_event("dev", "delete", Some("OLD_KEY"));
        });

        let content = std::fs::read_to_string(&outbox).unwrap();
        let lines: Vec<&str> = content.lines().collect();
        assert_eq!(lines.len(), 2, "should have two JSONL lines");

        for line in &lines {
            let v: serde_json::Value = serde_json::from_str(line).unwrap();
            assert_eq!(v["specversion"], "1.0");
            assert_eq!(v["datacontenttype"], "application/json");
            let source = v["source"].as_str().unwrap();
            assert!(source.starts_with("tsafe/dev"));
            // key name must not appear in the event
            assert!(!line.contains("SECRET_KEY"), "key name leaked into event");
            assert!(!line.contains("OLD_KEY"), "key name leaked into event");
        }
    }

    #[test]
    fn emit_noop_when_no_env_vars() {
        // Ensure neither outbox nor webhook env vars are set during this test.
        temp_env::with_vars(
            [
                ("TSAFE_EVENTS_OUTBOX", None::<&str>),
                ("TSAFE_EVENTS_WEBHOOK_URL", None),
                ("TSAFE_EVENTS_NATS_URL", None),
                ("TSAFE_EVENTS_NATS_SUBJECT", None),
            ],
            || {
                emit_event("dev", "get", Some("ANY_KEY")); // should be a silent noop
            },
        );
    }

    #[test]
    fn nats_adapter_requires_both_url_and_subject() {
        temp_env::with_vars(
            [
                ("TSAFE_EVENTS_NATS_URL", Some("nats://127.0.0.1:4222")),
                ("TSAFE_EVENTS_NATS_SUBJECT", None),
            ],
            || assert!(nats_adapter_from_env().is_none()),
        );

        temp_env::with_vars(
            [
                ("TSAFE_EVENTS_NATS_URL", Some(" ")),
                ("TSAFE_EVENTS_NATS_SUBJECT", Some("events.tsafe")),
            ],
            || assert!(nats_adapter_from_env().is_none()),
        );
    }

    #[test]
    fn nats_adapter_reads_trimmed_env_values() {
        temp_env::with_vars(
            [
                ("TSAFE_EVENTS_NATS_URL", Some(" nats://127.0.0.1:4222 ")),
                ("TSAFE_EVENTS_NATS_SUBJECT", Some(" events.tsafe ")),
            ],
            || {
                let cfg = nats_adapter_from_env().expect("expected NATS adapter config");
                assert_eq!(cfg.url, "nats://127.0.0.1:4222");
                assert_eq!(cfg.subject, "events.tsafe");
            },
        );
    }

    #[test]
    fn publish_nats_with_uses_expected_target_and_payload() {
        let cfg = NatsAdapterConfig {
            url: "nats://127.0.0.1:4222".into(),
            subject: "events.tsafe".into(),
        };
        let mut seen = None;
        publish_nats_with(
            &cfg,
            "{\"type\":\"com.tsafe.test.v1\"}",
            |url, subject, payload| {
                seen = Some((
                    url.to_string(),
                    subject.to_string(),
                    String::from_utf8(payload.to_vec()).unwrap(),
                ));
                Ok(())
            },
        )
        .unwrap();

        assert_eq!(
            seen,
            Some((
                "nats://127.0.0.1:4222".into(),
                "events.tsafe".into(),
                "{\"type\":\"com.tsafe.test.v1\"}".into(),
            ))
        );
    }

    #[test]
    fn emit_event_convenience_wrapper() {
        use tempfile::tempdir;
        let dir = tempdir().unwrap();
        let outbox = dir.path().join("e.jsonl");

        temp_env::with_var("TSAFE_EVENTS_OUTBOX", outbox.to_str(), || {
            emit_event("main", "init", None);
        });

        let content = std::fs::read_to_string(&outbox).unwrap();
        let line = content
            .lines()
            .next()
            .expect("expected one JSONL line from emit");
        let v: serde_json::Value = serde_json::from_str(line).unwrap();
        assert_eq!(v["type"], "com.tsafe.vault.created.v1");
    }

    // ── Task 6.2: no-plaintext-leak contract test ─────────────────────────
    //
    // This test verifies the no-plaintext-secret invariant (ADR-023) for the
    // outbox adapter. Secret values and plaintext key names must never appear
    // in any emitted event payload regardless of which adapter handles the
    // event.

    #[test]
    fn no_plaintext_secret_value_in_event_payload() {
        use tempfile::tempdir;
        let dir = tempdir().unwrap();
        let outbox = dir.path().join("no-leak.jsonl");

        // A representative set of ops that cover different lifecycle domains.
        let sensitive_key = "PROD_DB_PASSWORD";
        let sensitive_value = "super-secret-plaintext-value-12345";

        // Simulate a secret being set — key name must be absent, value is
        // never passed to the event layer at all.
        temp_env::with_var("TSAFE_EVENTS_OUTBOX", outbox.to_str(), || {
            emit_event("prod", "set", Some(sensitive_key));
            emit_event("prod", "get", Some(sensitive_key));
            emit_event("prod", "delete", Some(sensitive_key));
        });

        let content = std::fs::read_to_string(&outbox).unwrap();
        assert!(
            !content.contains(sensitive_key),
            "plaintext key name must not appear in any emitted event (adapter: outbox file)"
        );
        assert!(
            !content.contains(sensitive_value),
            "plaintext secret value must not appear in any emitted event (adapter: outbox file)"
        );

        // All lines must parse as valid JSON with a specversion field.
        for line in content.lines() {
            let v: serde_json::Value =
                serde_json::from_str(line).expect("each emitted line must be valid JSON");
            assert_eq!(v["specversion"], "1.0");
        }
    }

    #[test]
    fn no_plaintext_leak_in_exec_authority_event() {
        use crate::audit::{AuditContext, AuditEntry, AuditExecContext};
        use crate::contracts::{AuthorityContract, AuthorityNetworkPolicy, AuthorityTrust};

        let contract = AuthorityContract {
            name: "ci-deploy".into(),
            profile: Some("prod".into()),
            namespace: Some("infra".into()),
            access_profile: crate::rbac::RbacProfile::ReadOnly,
            allow_all_secrets: false,
            allowed_secrets: vec!["DATABASE_URL".into(), "API_SECRET".into()],
            required_secrets: vec!["DATABASE_URL".into()],
            allowed_targets: vec!["deploy.sh".into()],
            trust: AuthorityTrust::Hardened,
            network: AuthorityNetworkPolicy::Restricted,
        };

        let entry =
            AuditEntry::success("prod", "exec", None).with_context(AuditContext::from_exec(
                AuditExecContext::from_contract(&contract)
                    .with_target("/scripts/deploy.sh")
                    .with_injected_secrets(["DATABASE_URL"])
                    .with_missing_required_secrets(["API_SECRET"])
                    .with_dropped_env_names(["OPENAI_API_KEY"])
                    .with_target_evaluation(&contract.evaluate_target(Some("/scripts/deploy.sh"))),
            ));

        let ce = CloudEvent::from_audit(&entry);
        let serialised = serde_json::to_string(&ce).unwrap();

        // Plaintext secret names must not appear anywhere in the payload.
        assert!(
            !serialised.contains("DATABASE_URL"),
            "plaintext key name DATABASE_URL must not appear in exec authority event"
        );
        assert!(
            !serialised.contains("API_SECRET"),
            "plaintext key name API_SECRET must not appear in exec authority event"
        );

        // The authority sub-object must be present and use opaque refs.
        let auth = &ce.data["authority"]["exec"];
        assert!(
            !auth.is_null(),
            "authority.exec must be present for exec events"
        );
        let refs = auth["allowed_secret_refs"].as_array().unwrap();
        assert_eq!(refs.len(), 2, "two allowed secrets should produce two refs");
        for r in refs {
            let s = r.as_str().unwrap();
            assert_eq!(s.len(), 64, "each ref must be a 64-char SHA-256 hex string");
        }
    }
}
